def init(self) -> None: self.name = None self.network = None self.network_name = None self.commands = CommandManager() self.mx_register("m.room.message", self.on_mx_message)
def init(self) -> None: self.name = None self.network = None self.network_id = None self.network_name = None # deprecated self.media = [] self.lazy_members = {} # allow lazy joining your own ghost for echo self.commands = CommandManager() if type(self) == PrivateRoom: cmd = CommandParser(prog="WHOIS", description="WHOIS the other user") self.commands.register(cmd, self.cmd_whois) cmd = CommandParser( prog="MAXLINES", description= "set maximum number of lines per message until truncation or pastebin" ) cmd.add_argument("lines", type=int, nargs="?", help="Number of lines") self.commands.register(cmd, self.cmd_maxlines) cmd = CommandParser( prog="PASTEBIN", description="enable or disable automatic pastebin of long messages" ) cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable pastebin") cmd.add_argument("--disable", dest="enabled", action="store_false", help="Disable pastebin (messages will be truncated)") cmd.set_defaults(enabled=None) self.commands.register(cmd, self.cmd_pastebin) self.mx_register("m.room.message", self.on_mx_message) self.mx_register("m.room.redaction", self.on_mx_redaction)
def init(self): self.commands = CommandManager() cmd = CommandParser(prog="NETWORKS", description="list available networks") self.commands.register(cmd, self.cmd_networks) cmd = CommandParser(prog="SERVERS", description="list servers for a network") cmd.add_argument("network", help="network name (see NETWORKS)") self.commands.register(cmd, self.cmd_servers) cmd = CommandParser(prog="OPEN", description="open network for connecting") cmd.add_argument("name", help="network name (see NETWORKS)") self.commands.register(cmd, self.cmd_open) cmd = CommandParser( prog="QUIT", description="disconnect from all networks", epilog= ("For quickly leaving all networks and removing configurations in a single command.\n" "\n" "Additionally this will close current DM session with the bridge.\n" ), ) self.commands.register(cmd, self.cmd_quit) if self.serv.is_admin(self.user_id): cmd = CommandParser(prog="MASKS", description="list allow masks") self.commands.register(cmd, self.cmd_masks) cmd = CommandParser( prog="ADDMASK", description="add new allow mask", epilog= ("For anyone else than the owner to use this bridge they need to be allowed to talk with the bridge bot.\n" "This is accomplished by adding an allow mask that determines their permission level when using the bridge.\n" "\n" "Only admins can manage networks, normal users can just connect.\n" ), ) cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) cmd.add_argument("--admin", help="Admin level access", action="store_true") self.commands.register(cmd, self.cmd_addmask) cmd = CommandParser( prog="DELMASK", description="delete allow mask", epilog= ("Note: Removing a mask only prevents starting a new DM with the bridge bot. Use FORGET for ending existing" " sessions."), ) cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) self.commands.register(cmd, self.cmd_delmask) cmd = CommandParser(prog="ADDNETWORK", description="add new network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_addnetwork) cmd = CommandParser(prog="DELNETWORK", description="delete network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_delnetwork) cmd = CommandParser(prog="ADDSERVER", description="add server to a network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) cmd.add_argument("--tls", action="store_true", help="use TLS encryption", default=False) cmd.add_argument( "--tls-insecure", action="store_true", help= "ignore TLS verification errors (hostname, self-signed, expired)", default=False, ) self.commands.register(cmd, self.cmd_addserver) cmd = CommandParser(prog="DELSERVER", description="delete server from a network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) self.commands.register(cmd, self.cmd_delserver) cmd = CommandParser(prog="STATUS", description="list active users") self.commands.register(cmd, self.cmd_status) cmd = CommandParser( prog="FORGET", description= "remove all connections and configuration of a user", epilog= ("Kills all connections of this user, removes all user set configuration and makes the bridge leave all rooms" " where this user is in.\n" "If the user still has an allow mask they can DM the bridge again to reconfigure and reconnect.\n" "\n" "This is meant as a way to kick users after removing an allow mask or resetting a user after losing access to" " existing account/rooms for any reason.\n"), ) cmd.add_argument("user", help="Matrix ID (eg: @ex-friend:contoso.com)") self.commands.register(cmd, self.cmd_forget) cmd = CommandParser(prog="DISPLAYNAME", description="change bridge displayname") cmd.add_argument("displayname", help="new bridge displayname") self.commands.register(cmd, self.cmd_displayname) cmd = CommandParser(prog="AVATAR", description="change bridge avatar") cmd.add_argument("url", help="new avatar URL (mxc:// format)") self.commands.register(cmd, self.cmd_avatar) self.mx_register("m.room.message", self.on_mx_message)
class ControlRoom(Room): commands: CommandManager def init(self): self.commands = CommandManager() cmd = CommandParser(prog="NETWORKS", description="list available networks") self.commands.register(cmd, self.cmd_networks) cmd = CommandParser(prog="SERVERS", description="list servers for a network") cmd.add_argument("network", help="network name (see NETWORKS)") self.commands.register(cmd, self.cmd_servers) cmd = CommandParser(prog="OPEN", description="open network for connecting") cmd.add_argument("name", help="network name (see NETWORKS)") self.commands.register(cmd, self.cmd_open) cmd = CommandParser( prog="QUIT", description="disconnect from all networks", epilog= ("For quickly leaving all networks and removing configurations in a single command.\n" "\n" "Additionally this will close current DM session with the bridge.\n" ), ) self.commands.register(cmd, self.cmd_quit) if self.serv.is_admin(self.user_id): cmd = CommandParser(prog="MASKS", description="list allow masks") self.commands.register(cmd, self.cmd_masks) cmd = CommandParser( prog="ADDMASK", description="add new allow mask", epilog= ("For anyone else than the owner to use this bridge they need to be allowed to talk with the bridge bot.\n" "This is accomplished by adding an allow mask that determines their permission level when using the bridge.\n" "\n" "Only admins can manage networks, normal users can just connect.\n" ), ) cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) cmd.add_argument("--admin", help="Admin level access", action="store_true") self.commands.register(cmd, self.cmd_addmask) cmd = CommandParser( prog="DELMASK", description="delete allow mask", epilog= ("Note: Removing a mask only prevents starting a new DM with the bridge bot. Use FORGET for ending existing" " sessions."), ) cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) self.commands.register(cmd, self.cmd_delmask) cmd = CommandParser(prog="ADDNETWORK", description="add new network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_addnetwork) cmd = CommandParser(prog="DELNETWORK", description="delete network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_delnetwork) cmd = CommandParser(prog="ADDSERVER", description="add server to a network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) cmd.add_argument("--tls", action="store_true", help="use TLS encryption", default=False) cmd.add_argument( "--tls-insecure", action="store_true", help= "ignore TLS verification errors (hostname, self-signed, expired)", default=False, ) self.commands.register(cmd, self.cmd_addserver) cmd = CommandParser(prog="DELSERVER", description="delete server from a network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) self.commands.register(cmd, self.cmd_delserver) cmd = CommandParser(prog="STATUS", description="list active users") self.commands.register(cmd, self.cmd_status) cmd = CommandParser( prog="FORGET", description= "remove all connections and configuration of a user", epilog= ("Kills all connections of this user, removes all user set configuration and makes the bridge leave all rooms" " where this user is in.\n" "If the user still has an allow mask they can DM the bridge again to reconfigure and reconnect.\n" "\n" "This is meant as a way to kick users after removing an allow mask or resetting a user after losing access to" " existing account/rooms for any reason.\n"), ) cmd.add_argument("user", help="Matrix ID (eg: @ex-friend:contoso.com)") self.commands.register(cmd, self.cmd_forget) cmd = CommandParser(prog="DISPLAYNAME", description="change bridge displayname") cmd.add_argument("displayname", help="new bridge displayname") self.commands.register(cmd, self.cmd_displayname) cmd = CommandParser(prog="AVATAR", description="change bridge avatar") cmd.add_argument("url", help="new avatar URL (mxc:// format)") self.commands.register(cmd, self.cmd_avatar) self.mx_register("m.room.message", self.on_mx_message) def is_valid(self) -> bool: if self.user_id is None: return False if len(self.members) != 2: return False return True async def show_help(self): self.send_notice_html( f"<b>Howdy, stranger!</b> You have been granted access to the IRC bridge of <b>{self.serv.server_name}</b>." ) try: return await self.commands.trigger("HELP") except CommandParserError as e: return self.send_notice(str(e)) async def on_mx_message(self, event) -> bool: if event["content"]["msgtype"] != "m.text" or event[ "user_id"] == self.serv.user_id: return True try: return await self.commands.trigger(event["content"]["body"]) except CommandParserError as e: return self.send_notice(str(e)) def networks(self): networks = {} for network, config in self.serv.config["networks"].items(): config["name"] = network networks[network.lower()] = config return networks async def cmd_masks(self, args): msg = "Configured masks:\n" for mask, value in self.serv.config["allow"].items(): msg += "\t{} -> {}\n".format(mask, value) self.send_notice(msg) async def cmd_addmask(self, args): masks = self.serv.config["allow"] if args.mask in masks: return self.send_notice("Mask already exists") masks[args.mask] = "admin" if args.admin else "user" await self.serv.save() self.send_notice("Mask added.") async def cmd_delmask(self, args): masks = self.serv.config["allow"] if args.mask not in masks: return self.send_notice("Mask does not exist") del masks[args.mask] await self.serv.save() self.send_notice("Mask removed.") async def cmd_networks(self, args): networks = self.serv.config["networks"] self.send_notice("Configured networks:") for network, data in networks.items(): self.send_notice(f"\t{network} ({len(data['servers'])} servers)") async def cmd_addnetwork(self, args): networks = self.networks() if args.name.lower() in networks: return self.send_notice("Network already exists") self.serv.config["networks"][args.name] = {"servers": []} await self.serv.save() self.send_notice("Network added.") async def cmd_delnetwork(self, args): networks = self.networks() if args.name.lower() not in networks: return self.send_notice("Network does not exist") # FIXME: check if anyone is currently connected # FIXME: if no one is currently connected, leave from all network related rooms del self.serv.config["networks"][args.name] await self.serv.save() return self.send_notice("Network removed.") async def cmd_servers(self, args): networks = self.networks() if args.network.lower() not in networks: return self.send_notice("Network does not exist") network = networks[args.network.lower()] self.send_notice(f"Configured servers for {network['name']}:") for server in network["servers"]: with_tls = "" if server["tls"]: if "tls_insecure" in server and server["tls_insecure"]: with_tls = "with insecure TLS" else: with_tls = "with TLS" self.send_notice( f"\t{server['address']}:{server['port']} {with_tls}") async def cmd_addserver(self, args): networks = self.networks() if args.network.lower() not in networks: return self.send_notice("Network does not exist") network = networks[args.network.lower()] address = args.address.lower() for server in network["servers"]: if server["address"] == address and server["port"] == args.port: return self.send_notice("This server already exists.") self.serv.config["networks"][network["name"]]["servers"].append({ "address": address, "port": args.port, "tls": args.tls, "tls_insecure": args.tls_insecure }) await self.serv.save() self.send_notice("Server added.") async def cmd_delserver(self, args): networks = self.networks() if args.network.lower() not in networks: return self.send_notice("Network does not exist") network = networks[args.network.lower()] address = args.address.lower() to_pop = -1 for i, server in enumerate(network["servers"]): if server["address"] == address and server["port"] == args.port: to_pop = i break if to_pop == -1: return self.send_notice("No such server.") self.serv.config["networks"][network["name"]]["servers"].pop(to_pop) await self.serv.save() self.send_notice("Server deleted.") async def cmd_status(self, args): users = set() for room in self.serv.find_rooms(): users.add(room.user_id) users = list(users) users.sort() self.send_notice(f"I have {len(users)} known users:") for user in users: ncontrol = len(self.serv.find_rooms("ControlRoom", user)) self.send_notice(f"\t{user} ({ncontrol} open control rooms):") for network in self.serv.find_rooms("NetworkRoom", user): connected = "not connected" channels = "not on any channel" privates = "not in any DMs" if network.conn and network.conn.connected: connected = f"connected as {network.conn.real_nickname} ({network.get_username()})" nchannels = 0 nprivates = 0 for room in network.rooms.values(): if type(room).__name__ == "PrivateRoom": nprivates += 1 if type(room).__name__ == "ChannelRoom": nchannels += 1 if nprivates > 0: privates = f"in {nprivates} DMs" if nchannels > 0: channels = f"on {nchannels} channels" self.send_notice( f"\t\t{network.name}, {connected}, {channels}, {privates}") async def cmd_forget(self, args): if args.user == self.user_id: return self.send_notice("I can't forget you, silly!") rooms = self.serv.find_rooms(None, args.user) if len(rooms) == 0: return self.send_notice( "No such user. See STATUS for list of users.") # disconnect each network room in first pass for room in rooms: if type(room) == NetworkRoom and room.conn and room.conn.connected: self.send_notice( f"Disconnecting {args.user} from {room.name}...") await room.cmd_disconnect(Namespace()) self.send_notice( f"Leaving all {len(rooms)} rooms {args.user} was in...") # then just forget everything for room in rooms: self.serv.unregister_room(room.id) try: await self.serv.api.post_room_leave(room.id) except MatrixError: pass try: await self.serv.api.post_room_forget(room.id) except MatrixError: pass self.send_notice(f"Done, I have forgotten about {args.user}") async def cmd_displayname(self, args): try: await self.serv.api.put_user_displayname(self.serv.user_id, args.displayname) except MatrixError as e: self.send_notice(f"Failed to set displayname: {str(e)}") async def cmd_avatar(self, args): try: await self.serv.api.put_user_avatar_url(self.serv.user_id, args.url) except MatrixError as e: self.send_notice(f"Failed to set avatar: {str(e)}") async def cmd_open(self, args): networks = self.networks() name = args.name.lower() if name not in networks: return self.send_notice("Network does not exist") network = networks[name] for room in self.serv.find_rooms(NetworkRoom, self.user_id): if room.name == network["name"]: if self.user_id not in room.members: self.send_notice(f"Inviting back to {room.name}") await self.serv.api.post_room_invite(room.id, self.user_id) else: self.send_notice(f"You are already in {room.name}") return self.send_notice(f"You have been invited to {network['name']}") await NetworkRoom.create(self.serv, network["name"], self.user_id) async def cmd_quit(self, args): rooms = self.serv.find_rooms(None, self.user_id) # disconnect each network room in first pass for room in rooms: if type(room) == NetworkRoom and room.conn and room.conn.connected: self.send_notice(f"Disconnecting from {room.name}...") await room.cmd_disconnect(Namespace()) self.send_notice("Closing all channels and private messages...") # then just forget everything for room in rooms: if room.id == self.id: continue self.serv.unregister_room(room.id) try: await self.serv.api.post_room_leave(room.id) except MatrixError: pass try: await self.serv.api.post_room_forget(room.id) except MatrixError: pass self.send_notice("Goodbye!") await asyncio.sleep(1) raise RoomInvalidError("Leaving")
def init(self): self.name = None self.connected = False self.nick = None self.username = None self.ircname = None self.password = None self.sasl_username = None self.sasl_password = None self.autocmd = None self.commands = CommandManager() self.conn = None self.rooms = {} self.connlock = asyncio.Lock() self.disconnect = True self.real_host = "?" * 63 # worst case default self.keys = {} # temp dict of join channel keys cmd = CommandParser( prog="NICK", description="set/change nickname", epilog= ("You can always see your current nickname on the network without arguments.\n" "If connected new nickname will be sent to the server immediately. It may be rejected and an underscore appended" " to it automatically.\n"), ) cmd.add_argument("nick", nargs="?", help="new nickname") self.commands.register(cmd, self.cmd_nick) cmd = CommandParser( prog="USERNAME", description="set username", epilog= ("Setting a new username requires reconnecting to the network.\n" "\n" "Note: If you are a local user it will be replaced by the local part of your Matrix ID.\n" "Federated users are generated a shortened digest of their Matrix ID. Bridge admins have an" " exception where username will be respected and sent as their ident.\n" ), ) cmd.add_argument("username", nargs="?", help="new username") cmd.add_argument("--remove", action="store_true", help="remove stored username") self.commands.register(cmd, self.cmd_username) cmd = CommandParser( prog="IRCNAME", description="set ircname (realname)", epilog=( "Setting a new ircname requires reconnecting to the network.\n" ), ) cmd.add_argument("ircname", nargs="?", help="new ircname") cmd.add_argument("--remove", action="store_true", help="remove stored ircname") self.commands.register(cmd, self.cmd_ircname) cmd = CommandParser( prog="PASSWORD", description="set server password", epilog= ("You can store your network password using this command and it will be automatically offered on connect.\n" "Some networks allow using this to identify with NickServ on connect without sending a separate message.\n" "\n" "Note: Bridge administrators can trivially see the stored password if they want to.\n" ), ) cmd.add_argument("password", nargs="?", help="new password") cmd.add_argument("--remove", action="store_true", help="remove stored password") self.commands.register(cmd, self.cmd_password) cmd = CommandParser( prog="SASL", description="set SASL PLAIN credentials", epilog= ("If the network supports SASL authentication you can configure them with this command.\n" "\n" "Note: Bridge administrators can trivially see the stored password if they want to.\n" ), ) cmd.add_argument("--username", help="SASL username") cmd.add_argument("--password", help="SASL password") cmd.add_argument("--remove", action="store_true", help="remove stored credentials") self.commands.register(cmd, self.cmd_sasl) cmd = CommandParser( prog="AUTOCMD", description="run commands on connect", epilog= ("If the network you are connecting to does not support server password to identify you automatically" " can set this to send a command before joining channels.\n" "\n" 'Example (QuakeNet): AUTOCMD "UMODE +x; MSG [email protected] auth foo bar"\n' "Example (OFTC): AUTOCMD NICKSERV identify foo bar\n"), ) cmd.add_argument("command", nargs="*", help="commands separated with ';'") cmd.add_argument("--remove", action="store_true", help="remove stored command") self.commands.register(cmd, self.cmd_autocmd) cmd = CommandParser( prog="CONNECT", description="connect to network", epilog= ("When this command is invoked the connection to this network will be persisted across disconnects and" " bridge restart.\n" "Only if the server KILLs your connection it will stay disconnected until CONNECT is invoked again.\n" "\n" "If you want to cancel automatic reconnect you need to issue the DISCONNECT command.\n" ), ) self.commands.register(cmd, self.cmd_connect) cmd = CommandParser( prog="DISCONNECT", description="disconnect from network", epilog= ("In addition to disconnecting from an active network connection this will also cancel any automatic" "reconnection attempt.\n"), ) self.commands.register(cmd, self.cmd_disconnect) cmd = CommandParser(prog="RECONNECT", description="reconnect to network") self.commands.register(cmd, self.cmd_reconnect) cmd = CommandParser( prog="RAW", description="send raw IRC commands", epilog= ("Arguments (text) are not quoted in any way so it's possible to send ANY command to the server.\n" "This is meant as a last resort if the bridge does not have built-in support for some IRC command.\n" "\n" "Note: You may need to use colon (:) for multi-word arguments, see the IRC RFC for details.\n" ), ) cmd.add_argument("text", nargs="+", help="raw text") self.commands.register(cmd, self.cmd_raw) cmd = CommandParser( prog="QUERY", description="start a private chat", epilog= ("Creates a new DM with the target nick. They do not need to be connected for this command to work.\n" ), ) cmd.add_argument("nick", help="target nickname") cmd.add_argument("message", nargs="*", help="optional message") self.commands.register(cmd, self.cmd_query) cmd = CommandParser( prog="MSG", description="send a message without opening a DM", epilog= ("If the target nick does not exist on the network an error reply may be generated by the server.\n" ), ) cmd.add_argument("nick", help="target nickname") cmd.add_argument("message", nargs="+", help="message") self.commands.register(cmd, self.cmd_msg) cmd = CommandParser( prog="NICKSERV", description="send a message to NickServ (if supported by network)", epilog="Alias: NS", ) cmd.add_argument("message", nargs="+", help="message") self.commands.register(cmd, self.cmd_nickserv, ["NS"]) cmd = CommandParser( prog="CHANSERV", description="send a message to ChanServ (if supported by network)", epilog="Alias: CS", ) cmd.add_argument("message", nargs="+", help="message") self.commands.register(cmd, self.cmd_chanserv, ["CS"]) cmd = CommandParser( prog="JOIN", description="join a channel", epilog= ("Any channels joined will be persisted between reconnects.\n" "\n" "Note: Bridge administrators can trivially see the stored channel key if they want to.\n" ), ) cmd.add_argument("channel", help="target channel") cmd.add_argument("key", nargs="?", help="channel key") self.commands.register(cmd, self.cmd_join) cmd = CommandParser( prog="PLUMB", description="plumb a room", epilog= ("Plumbs a channel in single-puppeted mode. This will make the bridge join the room and then join the" " configured IRC channel.\n"), ) cmd.add_argument( "room", help="target Matrix room ID (eg. !uniqueid:your-homeserver)") cmd.add_argument("channel", help="target channel") cmd.add_argument("key", nargs="?", help="channel key") self.commands.register(cmd, self.cmd_plumb) cmd = CommandParser(prog="UMODE", description="set user modes") cmd.add_argument("flags", help="user mode flags") self.commands.register(cmd, self.cmd_umode) cmd = CommandParser( prog="WAIT", description="wait specified amount of time", epilog=("Use with AUTOCMD to add delays between commands."), ) cmd.add_argument("seconds", help="how many seconds to wait") self.commands.register(cmd, self.cmd_wait) self.mx_register("m.room.message", self.on_mx_message)
class NetworkRoom(Room): # configuration stuff name: str connected: bool nick: str username: str ircname: str password: str sasl_username: str sasl_password: str autocmd: str # state commands: CommandManager conn: Any rooms: Dict[str, Room] connecting: bool real_host: str def init(self): self.name = None self.connected = False self.nick = None self.username = None self.ircname = None self.password = None self.sasl_username = None self.sasl_password = None self.autocmd = None self.commands = CommandManager() self.conn = None self.rooms = {} self.connlock = asyncio.Lock() self.disconnect = True self.real_host = "?" * 63 # worst case default self.keys = {} # temp dict of join channel keys cmd = CommandParser( prog="NICK", description="set/change nickname", epilog= ("You can always see your current nickname on the network without arguments.\n" "If connected new nickname will be sent to the server immediately. It may be rejected and an underscore appended" " to it automatically.\n"), ) cmd.add_argument("nick", nargs="?", help="new nickname") self.commands.register(cmd, self.cmd_nick) cmd = CommandParser( prog="USERNAME", description="set username", epilog= ("Setting a new username requires reconnecting to the network.\n" "\n" "Note: If you are a local user it will be replaced by the local part of your Matrix ID.\n" "Federated users are generated a shortened digest of their Matrix ID. Bridge admins have an" " exception where username will be respected and sent as their ident.\n" ), ) cmd.add_argument("username", nargs="?", help="new username") cmd.add_argument("--remove", action="store_true", help="remove stored username") self.commands.register(cmd, self.cmd_username) cmd = CommandParser( prog="IRCNAME", description="set ircname (realname)", epilog=( "Setting a new ircname requires reconnecting to the network.\n" ), ) cmd.add_argument("ircname", nargs="?", help="new ircname") cmd.add_argument("--remove", action="store_true", help="remove stored ircname") self.commands.register(cmd, self.cmd_ircname) cmd = CommandParser( prog="PASSWORD", description="set server password", epilog= ("You can store your network password using this command and it will be automatically offered on connect.\n" "Some networks allow using this to identify with NickServ on connect without sending a separate message.\n" "\n" "Note: Bridge administrators can trivially see the stored password if they want to.\n" ), ) cmd.add_argument("password", nargs="?", help="new password") cmd.add_argument("--remove", action="store_true", help="remove stored password") self.commands.register(cmd, self.cmd_password) cmd = CommandParser( prog="SASL", description="set SASL PLAIN credentials", epilog= ("If the network supports SASL authentication you can configure them with this command.\n" "\n" "Note: Bridge administrators can trivially see the stored password if they want to.\n" ), ) cmd.add_argument("--username", help="SASL username") cmd.add_argument("--password", help="SASL password") cmd.add_argument("--remove", action="store_true", help="remove stored credentials") self.commands.register(cmd, self.cmd_sasl) cmd = CommandParser( prog="AUTOCMD", description="run commands on connect", epilog= ("If the network you are connecting to does not support server password to identify you automatically" " can set this to send a command before joining channels.\n" "\n" 'Example (QuakeNet): AUTOCMD "UMODE +x; MSG [email protected] auth foo bar"\n' "Example (OFTC): AUTOCMD NICKSERV identify foo bar\n"), ) cmd.add_argument("command", nargs="*", help="commands separated with ';'") cmd.add_argument("--remove", action="store_true", help="remove stored command") self.commands.register(cmd, self.cmd_autocmd) cmd = CommandParser( prog="CONNECT", description="connect to network", epilog= ("When this command is invoked the connection to this network will be persisted across disconnects and" " bridge restart.\n" "Only if the server KILLs your connection it will stay disconnected until CONNECT is invoked again.\n" "\n" "If you want to cancel automatic reconnect you need to issue the DISCONNECT command.\n" ), ) self.commands.register(cmd, self.cmd_connect) cmd = CommandParser( prog="DISCONNECT", description="disconnect from network", epilog= ("In addition to disconnecting from an active network connection this will also cancel any automatic" "reconnection attempt.\n"), ) self.commands.register(cmd, self.cmd_disconnect) cmd = CommandParser(prog="RECONNECT", description="reconnect to network") self.commands.register(cmd, self.cmd_reconnect) cmd = CommandParser( prog="RAW", description="send raw IRC commands", epilog= ("Arguments (text) are not quoted in any way so it's possible to send ANY command to the server.\n" "This is meant as a last resort if the bridge does not have built-in support for some IRC command.\n" "\n" "Note: You may need to use colon (:) for multi-word arguments, see the IRC RFC for details.\n" ), ) cmd.add_argument("text", nargs="+", help="raw text") self.commands.register(cmd, self.cmd_raw) cmd = CommandParser( prog="QUERY", description="start a private chat", epilog= ("Creates a new DM with the target nick. They do not need to be connected for this command to work.\n" ), ) cmd.add_argument("nick", help="target nickname") cmd.add_argument("message", nargs="*", help="optional message") self.commands.register(cmd, self.cmd_query) cmd = CommandParser( prog="MSG", description="send a message without opening a DM", epilog= ("If the target nick does not exist on the network an error reply may be generated by the server.\n" ), ) cmd.add_argument("nick", help="target nickname") cmd.add_argument("message", nargs="+", help="message") self.commands.register(cmd, self.cmd_msg) cmd = CommandParser( prog="NICKSERV", description="send a message to NickServ (if supported by network)", epilog="Alias: NS", ) cmd.add_argument("message", nargs="+", help="message") self.commands.register(cmd, self.cmd_nickserv, ["NS"]) cmd = CommandParser( prog="CHANSERV", description="send a message to ChanServ (if supported by network)", epilog="Alias: CS", ) cmd.add_argument("message", nargs="+", help="message") self.commands.register(cmd, self.cmd_chanserv, ["CS"]) cmd = CommandParser( prog="JOIN", description="join a channel", epilog= ("Any channels joined will be persisted between reconnects.\n" "\n" "Note: Bridge administrators can trivially see the stored channel key if they want to.\n" ), ) cmd.add_argument("channel", help="target channel") cmd.add_argument("key", nargs="?", help="channel key") self.commands.register(cmd, self.cmd_join) cmd = CommandParser( prog="PLUMB", description="plumb a room", epilog= ("Plumbs a channel in single-puppeted mode. This will make the bridge join the room and then join the" " configured IRC channel.\n"), ) cmd.add_argument( "room", help="target Matrix room ID (eg. !uniqueid:your-homeserver)") cmd.add_argument("channel", help="target channel") cmd.add_argument("key", nargs="?", help="channel key") self.commands.register(cmd, self.cmd_plumb) cmd = CommandParser(prog="UMODE", description="set user modes") cmd.add_argument("flags", help="user mode flags") self.commands.register(cmd, self.cmd_umode) cmd = CommandParser( prog="WAIT", description="wait specified amount of time", epilog=("Use with AUTOCMD to add delays between commands."), ) cmd.add_argument("seconds", help="how many seconds to wait") self.commands.register(cmd, self.cmd_wait) self.mx_register("m.room.message", self.on_mx_message) @staticmethod async def create(serv, name, user_id): room_id = await serv.create_room(name, "Network room for {}".format(name), [user_id]) room = NetworkRoom(room_id, user_id, serv, [serv.user_id, user_id]) room.from_config({"name": name}) await room.save() serv.register_room(room) await room.show_help() return room def from_config(self, config: dict): if "name" in config: self.name = config["name"] else: raise Exception("No name key in config for NetworkRoom") if "connected" in config: self.connected = config["connected"] if "nick" in config: self.nick = config["nick"] if "username" in config: self.username = config["username"] if "ircname" in config: self.ircname = config["ircname"] if "password" in config: self.password = config["password"] if "sasl_username" in config: self.sasl_username = config["sasl_username"] if "sasl_password" in config: self.sasl_password = config["sasl_password"] if "autocmd" in config: self.autocmd = config["autocmd"] def to_config(self) -> dict: return { "name": self.name, "connected": self.connected, "nick": self.nick, "username": self.username, "ircname": self.ircname, "password": self.password, "sasl_username": self.sasl_username, "sasl_password": self.sasl_password, "autocmd": self.autocmd, } def is_valid(self) -> bool: if self.name is None: return False # if user leaves network room and it's not connected we can clean it up if not self.in_room(self.user_id) and not self.connected: return False return True async def show_help(self): self.send_notice_html( "Welcome to the network room for <b>{}</b>!".format(self.name)) try: return await self.commands.trigger("HELP") except CommandParserError as e: return self.send_notice(str(e)) async def on_mx_message(self, event) -> None: if event["content"]["msgtype"] != "m.text" or event[ "user_id"] == self.serv.user_id: return True try: return await self.commands.trigger(event["content"]["body"]) except CommandParserError as e: return self.send_notice(str(e)) async def cmd_connect(self, args) -> None: await self.connect() async def cmd_disconnect(self, args) -> None: if not self.disconnect: self.send_notice("Aborting connection attempt after backoff.") self.disconnect = True if self.connected: self.connected = False await self.save() if self.conn: self.send_notice("Disconnecting...") self.conn.disconnect() @connected async def cmd_reconnect(self, args) -> None: self.send_notice("Reconnecting...") self.conn.disconnect() await self.connect() @connected async def cmd_raw(self, args) -> None: self.conn.send_raw(" ".join(args.text)) @connected async def cmd_query(self, args) -> None: # TODO: validate nick doesn't look like a channel target = args.nick.lower() message = " ".join(args.message) if target in self.rooms: room = self.rooms[target] await self.serv.api.post_room_invite(room.id, self.user_id) self.send_notice("Inviting back to private chat with {}.".format( args.nick)) else: room = PrivateRoom.create(self, args.nick) self.rooms[room.name] = room self.send_notice( "You have been invited to private chat with {}.".format( args.nick)) if len(message) > 0: self.conn.privmsg(target, message) self.send_notice( f"Sent out-of-room message to {target}: {message}") @connected async def cmd_msg(self, args) -> None: # TODO: validate nick doesn't look like a channel target = args.nick.lower() message = " ".join(args.message) self.conn.privmsg(target, message) self.send_notice(f"{self.conn.real_nickname} -> {target}: {message}") @connected async def cmd_nickserv(self, args) -> None: message = " ".join(args.message) self.send_notice(f"{self.conn.real_nickname} -> NickServ: {message}") self.conn.send_raw("NICKSERV " + message) @connected async def cmd_chanserv(self, args) -> None: message = " ".join(args.message) self.send_notice(f"{self.conn.real_nickname} -> ChanServ: {message}") self.conn.send_raw("CHANSERV " + message) @connected async def cmd_join(self, args) -> None: channel = args.channel if re.match(r"^[A-Za-z0-9]", channel): channel = "#" + channel # cache key so we can store later if join succeeds self.keys[channel.lower()] = args.key self.conn.join(channel, args.key) @connected async def cmd_plumb(self, args) -> None: channel = args.channel if re.match(r"^[A-Za-z0-9]", channel): channel = "#" + channel if not self.serv.is_admin(self.user_id): self.send_notice("Plumbing is currently reserved for admins only.") return room = await PlumbedRoom.create(id=args.room, network=self, channel=channel, key=args.key) self.conn.join(room.name, room.key) @connected async def cmd_umode(self, args) -> None: self.conn.mode(self.conn.real_nickname, args.flags) async def cmd_wait(self, args) -> None: try: seconds = float(args.seconds) if seconds > 0 and seconds < 30: await asyncio.sleep(seconds) else: self.send_notice(f"Unreasonable wait time: {args.seconds}") except ValueError: self.send_notice(f"Invalid wait time: {args.seconds}") def get_nick(self): if self.nick: return self.nick return self.user_id.split(":")[0][1:] async def cmd_nick(self, args) -> None: if args.nick is None: nick = self.get_nick() if self.conn and self.conn.connected: self.send_notice( f"Current nickname: {self.conn.real_nickname} (configured: {nick})" ) else: self.send_notice(f"Configured nickname: {nick}") return self.nick = args.nick await self.save() self.send_notice("Nickname set to {}".format(self.nick)) if self.conn and self.conn.connected: self.conn.nick(args.nick) def get_username(self): # allow admins to spoof if self.serv.is_admin(self.user_id) and self.username: return self.username parts = self.user_id.split(":") # return mxid digest if federated if parts[1] != self.serv.server_name: return ("mx-" + b32encode( hashlib.sha1(self.user_id.encode("utf-8")).digest()).decode( "utf-8").replace("=", "")[:13].lower()) # return local part of mx id for local users return parts[0][1:] async def cmd_username(self, args) -> None: if args.remove: self.username = None await self.save() self.send_notice("Username removed.") return if args.username is None: self.send_notice(f"Configured username: {str(self.username)}") return self.username = args.username await self.save() self.send_notice(f"Username set to {self.username}") async def cmd_ircname(self, args) -> None: if args.remove: self.ircname = None await self.save() self.send_notice("Ircname removed.") return if args.ircname is None: self.send_notice(f"Configured ircname: {str(self.ircname)}") return self.ircname = args.ircname await self.save() self.send_notice(f"Ircname set to {self.ircname}") async def cmd_password(self, args) -> None: if args.remove: self.password = None await self.save() self.send_notice("Password removed.") return if args.password is None: self.send_notice( f"Configured password: {self.password if self.password else ''}" ) return self.password = args.password await self.save() self.send_notice(f"Password set to {self.password}") async def cmd_sasl(self, args) -> None: if args.remove: self.sasl_username = None self.sasl_password = None await self.save() self.send_notice("SASL credentials removed.") return if args.username is None and args.password is None: self.send_notice(f"SASL username: {self.sasl_username}") self.send_notice(f"SASL password: {self.sasl_password}") return if args.username: self.sasl_username = args.username if args.password: self.sasl_password = args.password await self.save() self.send_notice("SASL credentials updated.") async def cmd_autocmd(self, args) -> None: autocmd = " ".join(args.command) if args.remove: self.autocmd = None await self.save() self.send_notice("Autocmd removed.") return if autocmd == "": self.send_notice( f"Configured autocmd: {self.autocmd if self.autocmd else ''}") return self.autocmd = autocmd await self.save() self.send_notice(f"Autocmd set to {self.autocmd}") async def connect(self) -> None: if self.connlock.locked(): self.send_notice("Already connecting.") return async with self.connlock: await self._connect() async def _connect(self) -> None: self.disconnect = False if self.conn and self.conn.connected: self.send_notice("Already connected.") return # attach loose sub-rooms to us for room in self.serv.find_rooms(PrivateRoom, self.user_id): if room.name not in self.rooms and room.network_name == self.name: logging.debug( f"NetworkRoom {self.id} attaching PrivateRoom {room.id}") room.network = self self.rooms[room.name] = room for room in self.serv.find_rooms(ChannelRoom, self.user_id): if room.name not in self.rooms and room.network_name == self.name: logging.debug( f"NetworkRoom {self.id} attaching ChannelRoom {room.id}") room.network = self self.rooms[room.name] = room for room in self.serv.find_rooms(PlumbedRoom, self.user_id): if room.name not in self.rooms and room.network_name == self.name: logging.debug( f"NetworkRoom {self.id} attaching PlumbedRoom {room.id}") room.network = self self.rooms[room.name] = room # force cleanup if self.conn: self.conn.close() self.conn = None network = self.serv.config["networks"][self.name] backoff = 10 while not self.disconnect: if self.name not in self.serv.config["networks"]: self.send_notice( "This network does not exist on this bridge anymore.") return if len(network["servers"]) == 0: self.connected = False self.send_notice("No servers to connect for this network.") await self.save() return for i, server in enumerate(network["servers"]): if i > 0: await asyncio.sleep(10) try: with_tls = "" ssl_ctx = False if server["tls"]: ssl_ctx = ssl.create_default_context() if "tls_insecure" in server and server["tls_insecure"]: with_tls = " with insecure TLS" ssl_ctx.check_hostname = False ssl_ctx.verify_mode = ssl.CERT_NONE else: with_tls = " with TLS" ssl_ctx.verify_mode = ssl.CERT_REQUIRED self.send_notice( f"Connecting to {server['address']}:{server['port']}{with_tls}..." ) if self.sasl_username and self.sasl_password: self.send_notice( f"Using SASL credentials for username {self.sasl_username}" ) reactor = HeisenReactor(loop=asyncio.get_event_loop()) irc_server = reactor.server() irc_server.buffer_class = buffer.LenientDecodingLineBuffer factory = irc.connection.AioFactory(ssl=ssl_ctx) self.conn = await irc_server.connect( server["address"], server["port"], self.get_nick(), self.password, username=self.username, ircname=self.ircname, connect_factory=factory, sasl_username=self.sasl_username, sasl_password=self.sasl_password, ) self.conn.add_global_handler("disconnect", self.on_disconnect) self.conn.add_global_handler("welcome", self.on_welcome) self.conn.add_global_handler("umodeis", self.on_umodeis) self.conn.add_global_handler("channelmodeis", self.on_pass0) self.conn.add_global_handler("channelcreate", self.on_pass0) self.conn.add_global_handler("notopic", self.on_pass0) self.conn.add_global_handler("currenttopic", self.on_pass0) self.conn.add_global_handler("topicinfo", self.on_pass0) self.conn.add_global_handler("namreply", self.on_pass1) self.conn.add_global_handler("endofnames", self.on_pass0) self.conn.add_global_handler("banlist", self.on_pass0) self.conn.add_global_handler("endofbanlist", self.on_pass0) # 400-599 self.conn.add_global_handler("nosuchnick", self.on_pass_if) self.conn.add_global_handler("nosuchchannel", self.on_pass_if) self.conn.add_global_handler("cannotsendtochan", self.on_pass_if) self.conn.add_global_handler("nicknameinuse", self.on_nicknameinuse) self.conn.add_global_handler("usernotinchannel", self.on_pass1) self.conn.add_global_handler("notonchannel", self.on_pass0) self.conn.add_global_handler("useronchannel", self.on_pass1) self.conn.add_global_handler("nologin", self.on_pass1) self.conn.add_global_handler("keyset", self.on_pass) self.conn.add_global_handler("channelisfull", self.on_pass) self.conn.add_global_handler("inviteonlychan", self.on_pass) self.conn.add_global_handler("bannedfromchan", self.on_pass) self.conn.add_global_handler("badchannelkey", self.on_pass0) self.conn.add_global_handler("badchanmask", self.on_pass) self.conn.add_global_handler("nochanmodes", self.on_pass) self.conn.add_global_handler("banlistfull", self.on_pass) self.conn.add_global_handler("cannotknock", self.on_pass) self.conn.add_global_handler("chanoprivsneeded", self.on_pass0) # protocol # FIXME: error self.conn.add_global_handler("join", self.on_join) self.conn.add_global_handler("join", self.on_join_update_host) self.conn.add_global_handler("kick", self.on_pass) self.conn.add_global_handler("mode", self.on_pass) self.conn.add_global_handler("part", self.on_pass) self.conn.add_global_handler("privmsg", self.on_privmsg) self.conn.add_global_handler("privnotice", self.on_privnotice) self.conn.add_global_handler("pubmsg", self.on_pass) self.conn.add_global_handler("pubnotice", self.on_pass) self.conn.add_global_handler("quit", self.on_quit) self.conn.add_global_handler("invite", self.on_invite) # FIXME: action self.conn.add_global_handler("topic", self.on_pass) self.conn.add_global_handler("nick", self.on_nick) self.conn.add_global_handler("umode", self.on_umode) self.conn.add_global_handler("kill", self.on_kill) self.conn.add_global_handler("error", self.on_error) # generated self.conn.add_global_handler("ctcp", self.on_ctcp) self.conn.add_global_handler("action", lambda conn, event: None) # anything not handled above self.conn.add_global_handler("unhandled_events", self.on_server_message) if not self.connected: self.connected = True await self.save() self.disconnect = False return except TimeoutError: self.send_notice("Connection timed out.") except irc.client.ServerConnectionError as e: self.send_notice(str(e)) logging.exception("Failed to connect") self.disconnect = True except Exception as e: self.send_notice(f"Failed to connect: {str(e)}") logging.exception("Failed to connect") if not self.disconnect: self.send_notice( f"Tried all servers, waiting {backoff} seconds before trying again." ) await asyncio.sleep(backoff) if backoff < 60: backoff += 5 self.send_notice("Connection aborted.") def on_disconnect(self, conn, event) -> None: self.conn.disconnect() self.conn.close() self.conn = None if self.connected and not self.disconnect: self.send_notice("Disconnected, reconnecting...") async def later(): await asyncio.sleep(10) if not self.disconnect: await self.connect() asyncio.ensure_future(later()) else: self.send_notice("Disconnected.") @ircroom_event() def on_pass(self, conn, event) -> None: logging.warning( f"IRC room event '{event.type}' fell through, target was from command." ) source = self.source_text(conn, event) args = " ".join(event.arguments) source = self.source_text(conn, event) target = str(event.target) self.send_notice_html(f"<b>{source} {event.type} {target}</b> {args}") @ircroom_event() def on_pass_if(self, conn, event) -> None: self.send_notice(" ".join(event.arguments)) @ircroom_event() def on_pass_or_ignore(self, conn, event) -> None: pass @ircroom_event(target_arg=0) def on_pass0(self, conn, event) -> None: logging.warning( f"IRC room event '{event.type}' fell through, target was '{event.arguments[0]}'." ) self.send_notice(" ".join(event.arguments)) @ircroom_event(target_arg=1) def on_pass1(self, conn, event) -> None: logging.warning( f"IRC room event '{event.type}' fell through, target was '{event.arguments[1]}'." ) self.send_notice(" ".join(event.arguments)) def on_server_message(self, conn, event) -> None: self.send_notice(" ".join(event.arguments)) def on_umodeis(self, conn, event) -> None: self.send_notice(f"Your user mode is: {event.arguments[0]}") def on_umode(self, conn, event) -> None: self.send_notice( f"User mode changed for {event.target}: {event.arguments[0]}") def source_text(self, conn, event) -> str: source = None if event.source is not None: source = str(event.source.nick) if event.source.user is not None and event.source.host is not None: source += f" ({event.source.user}@{event.source.host})" else: source = conn.server return source @ircroom_event() def on_privnotice(self, conn, event) -> None: # show unhandled notices in server room source = self.source_text(conn, event) self.send_notice_html( f"Notice from <b>{source}:</b> {event.arguments[0]}") @ircroom_event() def on_ctcp(self, conn, event) -> None: # show unhandled ctcps in server room source = self.source_text(conn, event) self.send_notice_html( f"<b>{source}</b> requested <b>CTCP {event.arguments[0]}</b> (ignored)" ) def on_welcome(self, conn, event) -> None: self.on_server_message(conn, event) async def later(): await asyncio.sleep(2) if self.autocmd is not None: self.send_notice( "Executing autocmd and waiting a bit before joining channels..." ) await self.commands.trigger(self.autocmd, allowed=[ "RAW", "MSG", "NICKSERV", "NS", "CHANSERV", "CS", "UMODE", "WAIT" ]) await asyncio.sleep(4) channels = [] keys = [] for room in self.rooms.values(): if type(room) is ChannelRoom or type(room) is PlumbedRoom: channels.append(room.name) keys.append(room.key if room.key else "") if len(channels) > 0: self.send_notice(f"Joining channels {', '.join(channels)}") self.conn.join(",".join(channels), ",".join(keys)) asyncio.ensure_future(later()) @ircroom_event() def on_privmsg(self, conn, event) -> None: # slightly backwards target = event.source.nick.lower() if target not in self.rooms: async def later(): # reuse query command to create a room await self.cmd_query( Namespace(nick=event.source.nick, message=[])) # push the message room = self.rooms[target] room.on_privmsg(conn, event) asyncio.ensure_future(later()) else: room = self.rooms[target] if not room.in_room(self.user_id): asyncio.ensure_future( self.serv.api.post_room_invite(self.rooms[target].id, self.user_id)) @ircroom_event() def on_join(self, conn, event) -> None: target = event.target.lower() logging.debug( f"Handling JOIN to {target} by {event.source.nick} (we are {self.conn.real_nickname})" ) # create a ChannelRoom in response to JOIN if event.source.nick == self.conn.real_nickname and target not in self.rooms: logging.debug( "Pre-flight check for JOIN ok, going to create it...") self.rooms[target] = ChannelRoom.create(self, event.target) # pass this event through self.rooms[target].on_join(conn, event) def on_join_update_host(self, conn, event) -> None: # update for split long if event.source.nick == self.conn.real_nickname and self.real_host != event.source.host: self.real_host = event.source.host logging.debug(f"Self host updated to '{self.real_host}'") def on_quit(self, conn, event) -> None: irc_user_id = self.serv.irc_user_id(self.name, event.source.nick) # leave channels for room in self.rooms.values(): if type(room) is ChannelRoom or type(room) is PlumbedRoom: room._remove_puppet(irc_user_id) def on_nick(self, conn, event) -> None: old_irc_user_id = self.serv.irc_user_id(self.name, event.source.nick) new_irc_user_id = self.serv.irc_user_id(self.name, event.target) # special case where only cases change, ensure will update displayname sometime in the future if old_irc_user_id == new_irc_user_id: asyncio.ensure_future( self.serv.ensure_irc_user_id(self.name, event.target)) # leave and join channels for room in self.rooms.values(): if type(room) is ChannelRoom or type(room) is PlumbedRoom: room.rename(event.source.nick, event.target) def on_nicknameinuse(self, conn, event) -> None: newnick = event.arguments[0] + "_" self.conn.nick(newnick) self.send_notice( f"Nickname {event.arguments[0]} is in use, trying {newnick}") def on_invite(self, conn, event) -> None: self.send_notice_html("<b>{}</b> has invited you to <b>{}</b>".format( event.source.nick, event.arguments[0])) @ircroom_event() def on_kill(self, conn, event) -> None: if event.target == conn.real_nickname: source = self.source_text(conn, event) self.send_notice_html( f"Killed by <b>{source}</b>: {event.arguments[0]}") # do not reconnect after KILL self.connected = False def on_error(self, conn, event) -> None: self.send_notice_html(f"<b>ERROR</b>: {event.target}")
def init(self): self.name = None self.connected = False self.nick = None self.username = None self.ircname = None self.password = None self.autocmd = None self.commands = CommandManager() self.conn = None self.rooms = {} self.connlock = asyncio.Lock() self.disconnect = True self.real_host = "?" * 63 # worst case default cmd = CommandParser(prog="NICK", description="Change nickname") cmd.add_argument("nick", nargs="?", help="new nickname") self.commands.register(cmd, self.cmd_nick) cmd = CommandParser(prog="USERNAME", description="Change username") cmd.add_argument("username", nargs="?", help="new username") cmd.add_argument("--remove", action="store_true", help="remove stored username") self.commands.register(cmd, self.cmd_username) cmd = CommandParser(prog="IRCNAME", description="Change ircname") cmd.add_argument("ircname", nargs="?", help="new ircname") cmd.add_argument("--remove", action="store_true", help="remove stored ircname") self.commands.register(cmd, self.cmd_ircname) cmd = CommandParser(prog="PASSWORD", description="Set server password") cmd.add_argument("password", nargs="?", help="new password") cmd.add_argument("--remove", action="store_true", help="remove stored password") self.commands.register(cmd, self.cmd_password) cmd = CommandParser( prog="AUTOCMD", description="Run a RAW IRC command on connect (to identify)") cmd.add_argument("command", nargs="*", help="raw IRC command") cmd.add_argument("--remove", action="store_true", help="remove stored command") self.commands.register(cmd, self.cmd_autocmd) cmd = CommandParser(prog="CONNECT", description="Connect to network") self.commands.register(cmd, self.cmd_connect) cmd = CommandParser(prog="DISCONNECT", description="Disconnect from network") self.commands.register(cmd, self.cmd_disconnect) cmd = CommandParser(prog="RECONNECT", description="Reconnect to network") self.commands.register(cmd, self.cmd_reconnect) cmd = CommandParser(prog="RAW", description="Send raw IRC commands") cmd.add_argument("text", nargs="+", help="raw text") self.commands.register(cmd, self.cmd_raw) cmd = CommandParser(prog="QUERY", description="Start a private chat") cmd.add_argument("nick", help="target nickname") cmd.add_argument("message", nargs="*", help="optional message") self.commands.register(cmd, self.cmd_query) cmd = CommandParser(prog="MSG", description="Send a message without opening a DM") cmd.add_argument("nick", help="target nickname") cmd.add_argument("message", nargs="+", help="message") self.commands.register(cmd, self.cmd_msg) cmd = CommandParser(prog="JOIN", description="Join a channel") cmd.add_argument("channel", help="target channel") cmd.add_argument("key", nargs="?", help="channel key") self.commands.register(cmd, self.cmd_join) self.mx_register("m.room.message", self.on_mx_message)
class PrivateRoom(Room): # irc nick of the other party, name for consistency name: str network: Optional[NetworkRoom] network_id: str network_name: Optional[str] media: List[List[str]] max_lines = 0 use_pastebin = True force_forward = False commands: CommandManager def init(self) -> None: self.name = None self.network = None self.network_id = None self.network_name = None # deprecated self.media = [] self.lazy_members = {} # allow lazy joining your own ghost for echo self.commands = CommandManager() if type(self) == PrivateRoom: cmd = CommandParser(prog="WHOIS", description="WHOIS the other user") self.commands.register(cmd, self.cmd_whois) cmd = CommandParser( prog="MAXLINES", description= "set maximum number of lines per message until truncation or pastebin" ) cmd.add_argument("lines", type=int, nargs="?", help="Number of lines") self.commands.register(cmd, self.cmd_maxlines) cmd = CommandParser( prog="PASTEBIN", description="enable or disable automatic pastebin of long messages" ) cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable pastebin") cmd.add_argument("--disable", dest="enabled", action="store_false", help="Disable pastebin (messages will be truncated)") cmd.set_defaults(enabled=None) self.commands.register(cmd, self.cmd_pastebin) self.mx_register("m.room.message", self.on_mx_message) self.mx_register("m.room.redaction", self.on_mx_redaction) def from_config(self, config: dict) -> None: if "max_lines" in config: self.max_lines = config["max_lines"] if "use_pastebin" in config: self.use_pastebin = config["use_pastebin"] if "name" not in config: raise Exception("No name key in config for ChatRoom") self.name = config["name"] if "network_id" in config: self.network_id = config["network_id"] if "media" in config: self.media = config["media"] # only used for migration if "network" in config: self.network_name = config["network"] if self.network_name is None and self.network_id is None: raise Exception( "No network or network_id key in config for PrivateRoom") def to_config(self) -> dict: return { "name": self.name, "network": self.network_name, "network_id": self.network_id, "media": self.media[:5], "max_lines": self.max_lines, "use_pastebin": self.use_pastebin, } @staticmethod def create(network: NetworkRoom, name: str) -> "PrivateRoom": logging.debug( f"PrivateRoom.create(network='{network.name}', name='{name}')") irc_user_id = network.serv.irc_user_id(network.name, name) room = PrivateRoom( None, network.user_id, network.serv, [irc_user_id, network.serv.user_id], [], ) room.name = name.lower() room.network = network room.network_id = network.id room.network_name = network.name room.max_lines = network.serv.config["max_lines"] room.use_pastebin = network.serv.config["use_pastebin"] asyncio.ensure_future(room._create_mx(name)) return room async def _create_mx(self, displayname) -> None: if self.id is None: irc_user_id = await self.network.serv.ensure_irc_user_id( self.network.name, displayname, update_cache=False) self.id = await self.network.serv.create_room( "{} ({})".format(displayname, self.network.name), "Private chat with {} on {}".format(displayname, self.network.name), [self.network.user_id, irc_user_id], ) self.serv.register_room(self) await self.az.intent.user(irc_user_id).ensure_joined(self.id) await self.save() # start event queue now that we have an id self._queue.start() # attach to network space if self.network.space: await self.network.space.attach(self.id) def is_valid(self) -> bool: if self.network_id is None and self.network_name is None: return False if self.name is None: return False if self.user_id is None: return False if not self.in_room(self.user_id): return False return True def cleanup(self) -> None: logging.debug(f"Cleaning up network connected room {self.id}.") # cleanup us from network space if we have it if self.network and self.network.space: asyncio.ensure_future(self.network.space.detach(self.id)) # cleanup us from network rooms if self.network and self.name in self.network.rooms: logging.debug( f"... and we are attached to network {self.network.id}, detaching." ) del self.network.rooms[self.name] # if leaving this room invalidated the network, clean it up if not self.network.is_valid(): logging.debug( f"... and we invalidated network {self.network.id} while cleaning up." ) self.network.serv.unregister_room(self.network.id) self.network.cleanup() asyncio.ensure_future( self.network.serv.leave_room(self.network.id, self.network.members)) super().cleanup() def send_notice( self, text: str, user_id: Optional[str] = None, formatted=None, fallback_html: Optional[str] = None, forward=False, ): if (self.force_forward or forward or self.network.forward) and user_id is None: self.network.send_notice(text=f"{self.name}: {text}", formatted=formatted, fallback_html=fallback_html) else: super().send_notice(text=text, user_id=user_id, formatted=formatted, fallback_html=fallback_html) def send_notice_html(self, text: str, user_id: Optional[str] = None, forward=False) -> None: if (self.force_forward or forward or self.network.forward) and user_id is None: self.network.send_notice_html(text=f"{self.name}: {text}") else: super().send_notice_html(text=text, user_id=user_id) def pills(self): # if pills are disabled, don't generate any if self.network.pills_length < 1: return None ret = {} ignore = list(map(lambda x: x.lower(), self.network.pills_ignore)) # push our own name first lnick = self.network.conn.real_nickname.lower() if self.user_id in self.displaynames and len( lnick) >= self.network.pills_length and lnick not in ignore: ret[lnick] = (self.user_id, self.displaynames[self.user_id]) # assuming displayname of a puppet matches nick for member in self.members: if not member.startswith( "@" + self.serv.puppet_prefix) or not member.endswith( ":" + self.serv.server_name): continue if member in self.displaynames: nick = self.displaynames[member] lnick = nick.lower() if len(nick ) >= self.network.pills_length and lnick not in ignore: ret[lnick] = (member, nick) return ret def on_privmsg(self, conn, event) -> None: if self.network is None: return irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) (plain, formatted) = parse_irc_formatting(event.arguments[0], self.pills()) # ignore relaymsgs by us if event.tags: for tag in event.tags: if tag["key"] == "draft/relaymsg" and tag[ "value"] == self.network.conn.real_nickname: return if event.source.nick == self.network.conn.real_nickname: source_irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) if self.lazy_members is None: self.send_message(f"You said: {plain}", formatted=(f"You said: {formatted}" if formatted else None)) return elif source_irc_user_id not in self.lazy_members: # if we are a PM room, remove all other IRC users than the target if type(self) == PrivateRoom: target_irc_user_id = self.serv.irc_user_id( self.network.name, self.name) for user_id in self.members: if user_id.startswith( "@" + self.serv.puppet_prefix ) and user_id != target_irc_user_id: if user_id in self.lazy_members: del self.lazy_members[user_id] self.leave(user_id) # add self to lazy members list so it'll echo self.lazy_members[source_irc_user_id] = event.source.nick self.send_message( plain, irc_user_id, formatted=formatted, fallback_html= f"<b>Message from {str(event.source)}</b>: {html.escape(plain)}", ) # lazy update displayname if we detect a change if (not self.serv.is_user_cached(irc_user_id, event.source.nick) and irc_user_id not in (self.lazy_members or {}) and irc_user_id in self.members): asyncio.ensure_future( self.serv.ensure_irc_user_id(self.network.name, event.source.nick)) def on_privnotice(self, conn, event) -> None: if self.network is None: return (plain, formatted) = parse_irc_formatting(event.arguments[0]) if event.source.nick == self.network.conn.real_nickname: self.send_notice( f"You noticed: {plain}", formatted=(f"You noticed: {formatted}" if formatted else None)) return # if the local user has left this room notify in network if self.user_id not in self.members: source = self.network.source_text(conn, event) self.network.send_notice_html( f"Notice from <b>{source}:</b> {formatted if formatted else html.escape(plain)}" ) return irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) self.send_notice( plain, irc_user_id, formatted=formatted, fallback_html= f"<b>Notice from {str(event.source)}</b>: {formatted if formatted else html.escape(plain)}", ) def on_ctcp(self, conn, event) -> None: if self.network is None: return # ignore relaymsgs by us if event.tags: for tag in event.tags: if tag["key"] == "draft/relaymsg" and tag[ "value"] == self.network.conn.real_nickname: return irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) command = event.arguments[0].upper() if command == "ACTION" and len(event.arguments) > 1: (plain, formatted) = parse_irc_formatting(event.arguments[1]) if event.source.nick == self.network.conn.real_nickname: self.send_emote(f"(you) {plain}") return self.send_emote( plain, irc_user_id, fallback_html= f"<b>Emote from {str(event.source)}</b>: {html.escape(plain)}") else: (plain, formatted) = parse_irc_formatting(" ".join(event.arguments)) self.send_notice_html( f"<b>{str(event.source)}</b> requested <b>CTCP {html.escape(plain)}</b> (ignored)" ) def on_ctcpreply(self, conn, event) -> None: if self.network is None: return (plain, formatted) = parse_irc_formatting(" ".join(event.arguments)) self.send_notice_html( f"<b>{str(event.source)}</b> sent <b>CTCP REPLY {html.escape(plain)}</b> (ignored)" ) async def _process_event_content(self, event, prefix, reply_to=None): content = event.content if content.formatted_body: lines = str(await self.parser.parse(content.formatted_body)).split("\n") elif content.body: body = content.body for user_id, displayname in self.displaynames.items(): body = body.replace(user_id, displayname) # FluffyChat prefixes mentions in fallback with @ body = body.replace("@" + displayname, displayname) lines = body.split("\n") else: logging.warning( "_process_event_content called with no usable body") return # drop all whitespace-only lines lines = [x for x in lines if not re.match(r"^\s*$", x)] # handle replies if reply_to and reply_to.sender != event.sender: # resolve displayname sender = reply_to.sender if sender in self.displaynames: sender = self.displaynames[sender] # prefix first line with nickname of the reply_to source first_line = sender + ": " + lines.pop(0) lines.insert(0, first_line) messages = [] for i, line in enumerate(lines): # prefix first line if needed if i == 0 and prefix and len(prefix) > 0: line = prefix + line # filter control characters except ZWSP line = "".join( c for c in line if unicodedata.category(c)[0] != "C" or c == "\u200B") messages += split_long( self.network.conn.real_nickname, self.network.real_user, self.network.real_host, self.name, line, ) return messages async def _send_message(self, event, func, prefix=""): # try to find out if this was a reply reply_to = None if event.content.get_reply_to(): rel_event = event # traverse back all edits while rel_event.content.get_edit(): rel_event = await self.az.intent.get_event( self.id, rel_event.content.get_edit()) # see if the original is a reply if rel_event.content.get_reply_to(): reply_to = await self.az.intent.get_event( self.id, rel_event.content.get_reply_to()) if event.content.get_edit(): messages = await self._process_event_content( event, prefix, reply_to) event_id = event.content.relates_to.event_id prev_event = self.last_messages[event.sender] if prev_event and prev_event.event_id == event_id: old_messages = await self._process_event_content( prev_event, prefix, reply_to) mlen = max(len(messages), len(old_messages)) edits = [] for i in range(0, mlen): try: old_msg = old_messages[i] except IndexError: old_msg = "" try: new_msg = messages[i] except IndexError: new_msg = "" edit = line_diff(old_msg, new_msg) if edit: edits.append(prefix + edit) # use edits only if one line was edited if len(edits) == 1: messages = edits # update last message _content_ to current so re-edits work self.last_messages[event.sender].content = event.content else: # last event was not found so we fall back to full message BUT we can reconstrut enough of it self.last_messages[event.sender] = event else: # keep track of the last message self.last_messages[event.sender] = event messages = await self._process_event_content( event, prefix, reply_to) for i, message in enumerate(messages): if self.max_lines > 0 and i == self.max_lines - 1 and len( messages) > self.max_lines: self.react(event.event_id, "\u2702") # scissors if self.use_pastebin: content_uri = await self.az.intent.upload_media( "\n".join(messages).encode("utf-8"), mime_type="text/plain; charset=UTF-8") if self.max_lines == 1: func( self.name, f"{prefix}{self.serv.mxc_to_url(str(content_uri))} (long message, {len(messages)} lines)", ) else: func( self.name, f"... long message truncated: {self.serv.mxc_to_url(str(content_uri))} ({len(messages)} lines)", ) self.react(event.event_id, "\U0001f4dd") # memo self.media.append([event.event_id, str(content_uri)]) await self.save() else: if self.max_lines == 1: # best effort is to send the first line and give up func(self.name, message) else: func(self.name, "... long message truncated") return func(self.name, message) # show number of lines sent to IRC if self.max_lines == 0 and len(messages) > 1: self.react(event.event_id, f"\u2702 {len(messages)} lines") async def on_mx_message(self, event) -> None: if event.sender != self.user_id: return if self.network is None or self.network.conn is None or not self.network.conn.connected: self.send_notice("Not connected to network.") return if str(event.content.msgtype) == "m.emote": await self._send_message(event, self.network.conn.action) elif str(event.content.msgtype) in [ "m.image", "m.file", "m.audio", "m.video" ]: self.network.conn.privmsg( self.name, self.serv.mxc_to_url(event.content.url, event.content.body)) self.react(event.event_id, "\U0001F517") # link self.media.append([event.event_id, event.content.url]) await self.save() elif str(event.content.msgtype) == "m.text": # allow commanding the appservice in rooms match = re.match(r"^\s*@?([^:,\s]+)[\s:,]*(.+)$", event.content.body) if match and match.group( 1).lower() == self.serv.registration["sender_localpart"]: try: await self.commands.trigger(match.group(2)) except CommandParserError as e: self.send_notice(str(e)) finally: return await self._send_message(event, self.network.conn.privmsg) await self.az.intent.send_receipt(event.room_id, event.event_id) async def on_mx_redaction(self, event) -> None: for media in self.media: if media[0] == event.redacts: url = urlparse(media[1]) if self.serv.synapse_admin: try: await self.az.intent.api.request( Method.POST, SynapseAdminPath.v1.media.quarantine[ url.netloc][url.path[1:]]) self.network.send_notice( f"Associated media {media[1]} for redacted event {event.redacts} " + f"in room {self.name} was quarantined.") except Exception: self.network.send_notice( f"Failed to quarantine media! Associated media {media[1]} " + f"for redacted event {event.redacts} in room {self.name} is left available." ) else: self.network.send_notice( f"No permission to quarantine media! Associated media {media[1]} " + f"for redacted event {event.redacts} in room {self.name} is left available." ) return @connected async def cmd_whois(self, args) -> None: self.network.conn.whois(f"{self.name} {self.name}") async def cmd_maxlines(self, args) -> None: if args.lines is not None: self.max_lines = args.lines await self.save() self.send_notice(f"Max lines is {self.max_lines}") async def cmd_pastebin(self, args) -> None: if args.enabled is not None: self.use_pastebin = args.enabled await self.save() self.send_notice( f"Pastebin is {'enabled' if self.use_pastebin else 'disabled'}")
class NetworkRoom(Room): # configuration stuff name: str connected: bool nick: str username: str ircname: str password: str autocmd: str # state commands: CommandManager conn: Any rooms: Dict[str, Room] connecting: bool real_host: str def init(self): self.name = None self.connected = False self.nick = None self.username = None self.ircname = None self.password = None self.autocmd = None self.commands = CommandManager() self.conn = None self.rooms = {} self.connlock = asyncio.Lock() self.disconnect = True self.real_host = "?" * 63 # worst case default cmd = CommandParser(prog="NICK", description="Change nickname") cmd.add_argument("nick", nargs="?", help="new nickname") self.commands.register(cmd, self.cmd_nick) cmd = CommandParser(prog="USERNAME", description="Change username") cmd.add_argument("username", nargs="?", help="new username") cmd.add_argument("--remove", action="store_true", help="remove stored username") self.commands.register(cmd, self.cmd_username) cmd = CommandParser(prog="IRCNAME", description="Change ircname") cmd.add_argument("ircname", nargs="?", help="new ircname") cmd.add_argument("--remove", action="store_true", help="remove stored ircname") self.commands.register(cmd, self.cmd_ircname) cmd = CommandParser(prog="PASSWORD", description="Set server password") cmd.add_argument("password", nargs="?", help="new password") cmd.add_argument("--remove", action="store_true", help="remove stored password") self.commands.register(cmd, self.cmd_password) cmd = CommandParser( prog="AUTOCMD", description="Run a RAW IRC command on connect (to identify)") cmd.add_argument("command", nargs="*", help="raw IRC command") cmd.add_argument("--remove", action="store_true", help="remove stored command") self.commands.register(cmd, self.cmd_autocmd) cmd = CommandParser(prog="CONNECT", description="Connect to network") self.commands.register(cmd, self.cmd_connect) cmd = CommandParser(prog="DISCONNECT", description="Disconnect from network") self.commands.register(cmd, self.cmd_disconnect) cmd = CommandParser(prog="RECONNECT", description="Reconnect to network") self.commands.register(cmd, self.cmd_reconnect) cmd = CommandParser(prog="RAW", description="Send raw IRC commands") cmd.add_argument("text", nargs="+", help="raw text") self.commands.register(cmd, self.cmd_raw) cmd = CommandParser(prog="QUERY", description="Start a private chat") cmd.add_argument("nick", help="target nickname") cmd.add_argument("message", nargs="*", help="optional message") self.commands.register(cmd, self.cmd_query) cmd = CommandParser(prog="MSG", description="Send a message without opening a DM") cmd.add_argument("nick", help="target nickname") cmd.add_argument("message", nargs="+", help="message") self.commands.register(cmd, self.cmd_msg) cmd = CommandParser(prog="JOIN", description="Join a channel") cmd.add_argument("channel", help="target channel") cmd.add_argument("key", nargs="?", help="channel key") self.commands.register(cmd, self.cmd_join) self.mx_register("m.room.message", self.on_mx_message) @staticmethod async def create(serv, name, user_id): room_id = await serv.create_room(name, "Network room for {}".format(name), [user_id]) room = NetworkRoom(room_id, user_id, serv, [serv.user_id, user_id]) room.from_config({"name": name}) await room.save() serv.register_room(room) await room.show_help() return room def from_config(self, config: dict): if "name" in config: self.name = config["name"] else: raise Exception("No name key in config for NetworkRoom") if "connected" in config: self.connected = config["connected"] if "nick" in config: self.nick = config["nick"] if "username" in config: self.username = config["username"] if "ircname" in config: self.ircname = config["ircname"] if "password" in config: self.password = config["password"] if "autocmd" in config: self.autocmd = config["autocmd"] def to_config(self) -> dict: return { "name": self.name, "connected": self.connected, "nick": self.nick, "username": self.username, "ircname": self.ircname, "password": self.password, "autocmd": self.autocmd, } def is_valid(self) -> bool: if self.name is None: return False # if user leaves network room and it's not connected we can clean it up if not self.in_room(self.user_id) and not self.connected: return False return True async def show_help(self): self.send_notice_html( "Welcome to the network room for <b>{}</b>!".format(self.name)) try: return await self.commands.trigger("HELP") except CommandParserError as e: return self.send_notice(str(e)) async def on_mx_message(self, event) -> None: if event["content"]["msgtype"] != "m.text" or event[ "user_id"] == self.serv.user_id: return True try: return await self.commands.trigger(event["content"]["body"]) except CommandParserError as e: return self.send_notice(str(e)) async def cmd_connect(self, args) -> None: await self.connect() async def cmd_disconnect(self, args) -> None: if not self.disconnect: self.send_notice("Aborting connection attempt after backoff.") self.disconnect = True if self.connected: self.connected = False await self.save() if self.conn: self.send_notice("Disconnecting...") self.conn.disconnect() @connected async def cmd_reconnect(self, args) -> None: self.send_notice("Reconnecting...") self.conn.disconnect() await self.connect() @connected async def cmd_raw(self, args) -> None: self.conn.send_raw(" ".join(args.text)) @connected async def cmd_query(self, args) -> None: # TODO: validate nick doesn't look like a channel target = args.nick.lower() message = " ".join(args.message) if target in self.rooms: room = self.rooms[target] await self.serv.api.post_room_invite(room.id, self.user_id) self.send_notice("Inviting back to private chat with {}.".format( args.nick)) else: room = PrivateRoom.create(self, args.nick) self.rooms[room.name] = room self.send_notice( "You have been invited to private chat with {}.".format( args.nick)) if len(message) > 0: self.conn.privmsg(target, message) self.send_notice( f"Sent out-of-room message to {target}: {message}") @connected async def cmd_msg(self, args) -> None: # TODO: validate nick doesn't look like a channel target = args.nick.lower() message = " ".join(args.message) self.conn.privmsg(target, message) self.send_notice(f"{self.conn.real_nickname} -> {target}: {message}") @connected async def cmd_join(self, args) -> None: channel = args.channel if re.match(r"^[A-Za-z0-9]", channel): channel = "#" + channel self.conn.join(channel, args.key) def get_nick(self): if self.nick: return self.nick return self.user_id.split(":")[0][1:] async def cmd_nick(self, args) -> None: if args.nick is None: nick = self.get_nick() if self.conn and self.conn.connected: self.send_notice( f"Current nickname: {self.conn.real_nickname} (configured: {nick})" ) else: self.send_notice(f"Configured nickname: {nick}") return self.nick = args.nick await self.save() self.send_notice("Nickname set to {}".format(self.nick)) if self.conn and self.conn.connected: self.conn.nick(args.nick) def get_username(self): # allow admins to spoof if self.serv.is_admin(self.user_id) and self.username: return self.username parts = self.user_id.split(":") # disallow identd response for remote users if parts[1] != self.serv.server_name: return None # return local part of mx id for local users return parts[0][1:] async def cmd_username(self, args) -> None: if args.remove: self.username = None await self.save() self.send_notice("Username removed.") return if args.username is None: self.send_notice(f"Configured username: {str(self.username)}") return self.username = args.username await self.save() self.send_notice(f"Username set to {self.username}") async def cmd_ircname(self, args) -> None: if args.remove: self.ircname = None await self.save() self.send_notice("Ircname removed.") return if args.ircname is None: self.send_notice(f"Configured ircname: {str(self.ircname)}") return self.ircname = args.ircname await self.save() self.send_notice(f"Ircname set to {self.ircname}") async def cmd_password(self, args) -> None: if args.remove: self.password = None await self.save() self.send_notice("Password removed.") return if args.password is None: self.send_notice( f"Configured password: {self.password if self.password else ''}" ) return self.password = args.password await self.save() self.send_notice(f"Password set to {self.password}") async def cmd_autocmd(self, args) -> None: autocmd = " ".join(args.command) if args.remove: self.autocmd = None await self.save() self.send_notice("Autocmd removed.") return if autocmd == "": self.send_notice( f"Configured autocmd: {self.autocmd if self.autocmd else ''}") return self.autocmd = autocmd await self.save() self.send_notice(f"Autocmd set to {self.autocmd}") async def connect(self) -> None: if self.connlock.locked(): self.send_notice("Already connecting.") return async with self.connlock: await self._connect() async def _connect(self) -> None: self.disconnect = False if self.conn and self.conn.connected: self.send_notice("Already connected.") return # attach loose sub-rooms to us for room in self.serv.find_rooms(PrivateRoom, self.user_id): if room.name not in self.rooms and room.network_name == self.name: logging.debug( f"NetworkRoom {self.id} attaching PrivateRoom {room.id}") room.network = self self.rooms[room.name] = room for room in self.serv.find_rooms(ChannelRoom, self.user_id): if room.name not in self.rooms and room.network_name == self.name: logging.debug( f"NetworkRoom {self.id} attaching ChannelRoom {room.id}") room.network = self self.rooms[room.name] = room # force cleanup if self.conn: self.conn = None network = self.serv.config["networks"][self.name] backoff = 10 while not self.disconnect: if self.name not in self.serv.config["networks"]: self.send_notice( "This network does not exist on this bridge anymore.") return if len(network["servers"]) == 0: self.connected = False self.send_notice("No servers to connect for this network.") await self.save() return for i, server in enumerate(network["servers"]): if i > 0: await asyncio.sleep(10) try: self.send_notice( f"Connecting to {server['address']}:{server['port']}{' with TLS' if server['tls'] else ''}..." ) reactor = irc.client_aio.AioReactor( loop=asyncio.get_event_loop()) irc_server = reactor.server() irc_server.buffer_class = buffer.LenientDecodingLineBuffer factory = irc.connection.AioFactory(ssl=server["tls"]) self.conn = await irc_server.connect( server["address"], server["port"], self.get_nick(), self.password, username=self.username, ircname=self.ircname, connect_factory=factory, ) self.conn.add_global_handler("disconnect", self.on_disconnect) # 001-099 self.conn.add_global_handler("welcome", self.on_welcome) self.conn.add_global_handler("yourhost", self.on_server_message) self.conn.add_global_handler("created", self.on_server_message) self.conn.add_global_handler("myinfo", self.on_server_message) self.conn.add_global_handler("featurelist", self.on_server_message) self.conn.add_global_handler("020", self.on_server_message) # 200-299 self.conn.add_global_handler("tracelink", self.on_server_message) self.conn.add_global_handler("traceconnecting", self.on_server_message) self.conn.add_global_handler("tracehandshake", self.on_server_message) self.conn.add_global_handler("traceunknown", self.on_server_message) self.conn.add_global_handler("traceoperator", self.on_server_message) self.conn.add_global_handler("traceuser", self.on_server_message) self.conn.add_global_handler("traceserver", self.on_server_message) self.conn.add_global_handler("traceservice", self.on_server_message) self.conn.add_global_handler("tracenewtype", self.on_server_message) self.conn.add_global_handler("traceclass", self.on_server_message) self.conn.add_global_handler("tracereconnect", self.on_server_message) self.conn.add_global_handler("statslinkinfo", self.on_server_message) self.conn.add_global_handler("statscommands", self.on_server_message) self.conn.add_global_handler("statscline", self.on_server_message) self.conn.add_global_handler("statsnline", self.on_server_message) self.conn.add_global_handler("statsiline", self.on_server_message) self.conn.add_global_handler("statskline", self.on_server_message) self.conn.add_global_handler("statsqline", self.on_server_message) self.conn.add_global_handler("statsyline", self.on_server_message) self.conn.add_global_handler("endofstats", self.on_server_message) self.conn.add_global_handler("umodeis", self.on_umodeis) self.conn.add_global_handler("serviceinfo", self.on_server_message) self.conn.add_global_handler("endofservices", self.on_server_message) self.conn.add_global_handler("service", self.on_server_message) self.conn.add_global_handler("servlist", self.on_server_message) self.conn.add_global_handler("servlistend", self.on_server_message) self.conn.add_global_handler("statslline", self.on_server_message) self.conn.add_global_handler("statsuptime", self.on_server_message) self.conn.add_global_handler("statsoline", self.on_server_message) self.conn.add_global_handler("statshline", self.on_server_message) self.conn.add_global_handler("luserconns", self.on_server_message) self.conn.add_global_handler("luserclient", self.on_server_message) self.conn.add_global_handler("luserop", self.on_server_message) self.conn.add_global_handler("luserunknown", self.on_server_message) self.conn.add_global_handler("luserchannels", self.on_server_message) self.conn.add_global_handler("luserme", self.on_server_message) self.conn.add_global_handler("adminme", self.on_server_message) self.conn.add_global_handler("adminloc1", self.on_server_message) self.conn.add_global_handler("adminloc2", self.on_server_message) self.conn.add_global_handler("adminemail", self.on_server_message) self.conn.add_global_handler("tracelog", self.on_server_message) self.conn.add_global_handler("endoftrace", self.on_server_message) self.conn.add_global_handler("tryagain", self.on_server_message) self.conn.add_global_handler("n_local", self.on_server_message) self.conn.add_global_handler("n_global", self.on_server_message) # 300-399 self.conn.add_global_handler("none", self.on_server_message) self.conn.add_global_handler("away", self.on_server_message) self.conn.add_global_handler("userhost", self.on_server_message) self.conn.add_global_handler("ison", self.on_server_message) self.conn.add_global_handler("unaway", self.on_server_message) self.conn.add_global_handler("nowaway", self.on_server_message) self.conn.add_global_handler("whoisuser", self.on_server_message) self.conn.add_global_handler("whoisserver", self.on_server_message) self.conn.add_global_handler("whoisoperator", self.on_server_message) self.conn.add_global_handler("whowasuser", self.on_server_message) self.conn.add_global_handler("endofwho", self.on_server_message) self.conn.add_global_handler("whoischanop", self.on_server_message) self.conn.add_global_handler("whoisidle", self.on_server_message) self.conn.add_global_handler("endofwhois", self.on_server_message) self.conn.add_global_handler("whoischannels", self.on_server_message) self.conn.add_global_handler("liststart", self.on_server_message) self.conn.add_global_handler("list", self.on_server_message) self.conn.add_global_handler("listend", self.on_server_message) self.conn.add_global_handler("channelmodeis", self.on_pass0) self.conn.add_global_handler("channelcreate", self.on_pass0) self.conn.add_global_handler("whoisaccount", self.on_server_message) self.conn.add_global_handler("notopic", self.on_pass) self.conn.add_global_handler("currenttopic", self.on_pass0) # self.conn.add_global_handler("topicinfo", self.on_server_message) # not needed right now self.conn.add_global_handler("inviting", self.on_server_message) self.conn.add_global_handler("summoning", self.on_server_message) self.conn.add_global_handler("invitelist", self.on_server_message) self.conn.add_global_handler("endofinvitelist", self.on_server_message) self.conn.add_global_handler("exceptlist", self.on_server_message) self.conn.add_global_handler("endofexceptlist", self.on_server_message) self.conn.add_global_handler("version", self.on_server_message) self.conn.add_global_handler("whoreply", self.on_server_message) self.conn.add_global_handler("namreply", self.on_pass1) self.conn.add_global_handler("whospcrpl", self.on_server_message) self.conn.add_global_handler("killdone", self.on_server_message) self.conn.add_global_handler("closing", self.on_server_message) self.conn.add_global_handler("closeend", self.on_server_message) self.conn.add_global_handler("links", self.on_server_message) self.conn.add_global_handler("endoflinks", self.on_server_message) self.conn.add_global_handler("endofnames", self.on_pass0) self.conn.add_global_handler("banlist", self.on_pass0) self.conn.add_global_handler("endofbanlist", self.on_pass0) self.conn.add_global_handler("endofwhowas", self.on_server_message) self.conn.add_global_handler("info", self.on_server_message) self.conn.add_global_handler("motd", self.on_server_message) self.conn.add_global_handler("infostart", self.on_server_message) self.conn.add_global_handler("endofinfo", self.on_server_message) self.conn.add_global_handler("motdstart", self.on_server_message) self.conn.add_global_handler("endofmotd", self.on_server_message) self.conn.add_global_handler("youreoper", self.on_server_message) self.conn.add_global_handler( "396", self.on_server_message) # new host # 400-599 self.conn.add_global_handler("nosuchnick", self.on_pass_if) self.conn.add_global_handler("nosuchserver", self.on_server_message) self.conn.add_global_handler("nosuchchannel", self.on_pass_if) self.conn.add_global_handler("cannotsendtochan", self.on_pass_if) self.conn.add_global_handler("toomanychannels", self.on_server_message) self.conn.add_global_handler("wasnosuchnick", self.on_server_message) self.conn.add_global_handler("toomanytargets", self.on_server_message) self.conn.add_global_handler("noorigin", self.on_server_message) self.conn.add_global_handler("invalidcapcmd", self.on_server_message) self.conn.add_global_handler("norecipient", self.on_server_message) self.conn.add_global_handler("notexttosend", self.on_server_message) self.conn.add_global_handler("notoplevel", self.on_server_message) self.conn.add_global_handler("wildtoplevel", self.on_server_message) self.conn.add_global_handler("unknowncommand", self.on_server_message) self.conn.add_global_handler("nomotd", self.on_server_message) self.conn.add_global_handler("noadmininfo", self.on_server_message) self.conn.add_global_handler("fileerror", self.on_server_message) self.conn.add_global_handler("nonicknamegiven", self.on_server_message) self.conn.add_global_handler("erroneusnickname", self.on_server_message) self.conn.add_global_handler("nicknameinuse", self.on_nicknameinuse) self.conn.add_global_handler("nickcollision", self.on_server_message) self.conn.add_global_handler("unavailresource", self.on_server_message) self.conn.add_global_handler("unavailresource", self.on_server_message) self.conn.add_global_handler("usernotinchannel", self.on_pass1) self.conn.add_global_handler("notonchannel", self.on_pass0) self.conn.add_global_handler("useronchannel", self.on_pass1) self.conn.add_global_handler("nologin", self.on_pass1) self.conn.add_global_handler("summondisabled", self.on_server_message) self.conn.add_global_handler("usersdisabled", self.on_server_message) self.conn.add_global_handler("notregistered", self.on_server_message) self.conn.add_global_handler("needmoreparams", self.on_server_message) self.conn.add_global_handler("alreadyregistered", self.on_server_message) self.conn.add_global_handler("nopermforhost", self.on_server_message) self.conn.add_global_handler("passwdmismatch", self.on_server_message) self.conn.add_global_handler("yourebannedcreep", self.on_server_message) self.conn.add_global_handler("youwillbebanned", self.on_server_message) self.conn.add_global_handler("keyset", self.on_pass) self.conn.add_global_handler("channelisfull", self.on_pass) self.conn.add_global_handler("unknownmode", self.on_server_message) self.conn.add_global_handler("inviteonlychan", self.on_pass) self.conn.add_global_handler("bannedfromchan", self.on_pass) self.conn.add_global_handler("badchannelkey", self.on_pass0) self.conn.add_global_handler("badchanmask", self.on_pass) self.conn.add_global_handler("nochanmodes", self.on_pass) self.conn.add_global_handler("banlistfull", self.on_pass) self.conn.add_global_handler("cannotknock", self.on_pass) self.conn.add_global_handler("noprivileges", self.on_server_message) self.conn.add_global_handler("chanoprivsneeded", self.on_pass) self.conn.add_global_handler("cantkillserver", self.on_server_message) self.conn.add_global_handler("restricted", self.on_server_message) self.conn.add_global_handler("uniqopprivsneeded", self.on_server_message) self.conn.add_global_handler("nooperhost", self.on_server_message) self.conn.add_global_handler("noservicehost", self.on_server_message) self.conn.add_global_handler("umodeunknownflag", self.on_server_message) self.conn.add_global_handler("usersdontmatch", self.on_server_message) # protocol # FIXME: error self.conn.add_global_handler("join", self.on_join) self.conn.add_global_handler("join", self.on_join_update_host) self.conn.add_global_handler("kick", self.on_pass) self.conn.add_global_handler("mode", self.on_pass) self.conn.add_global_handler("part", self.on_pass) self.conn.add_global_handler("privmsg", self.on_privmsg) self.conn.add_global_handler("privnotice", self.on_privnotice) self.conn.add_global_handler("pubmsg", self.on_pass) self.conn.add_global_handler("pubnotice", self.on_pass) self.conn.add_global_handler("quit", self.on_quit) self.conn.add_global_handler("invite", self.on_invite) # FIXME: action self.conn.add_global_handler("topic", self.on_pass) self.conn.add_global_handler("nick", self.on_nick) self.conn.add_global_handler("umode", self.on_umode) self.conn.add_global_handler("kill", self.on_kill) self.conn.add_global_handler("error", self.on_error) # generated self.conn.add_global_handler("ctcp", self.on_ctcp) if not self.connected: self.connected = True await self.save() self.disconnect = False return except TimeoutError: self.send_notice("Connection timed out.") except irc.client.ServerConnectionError: self.send_notice( "Unexpected connection error, issue was logged.") logging.exception("Failed to connect") except Exception as e: self.send_notice(f"Failed to connect: {str(e)}") logging.exception("Failed to connect") if not self.disconnect: self.send_notice( f"Tried all servers, waiting {backoff} seconds before trying again." ) await asyncio.sleep(backoff) if backoff < 60: backoff += 5 self.send_notice("Connection aborted.") def on_disconnect(self, conn, event) -> None: self.conn.disconnect() self.conn = None if self.connected and not self.disconnect: self.send_notice("Disconnected, reconnecting...") async def later(): await asyncio.sleep(10) await self.connect() asyncio.ensure_future(later()) else: self.send_notice("Disconnected.") @ircroom_event() def on_pass(self, conn, event) -> None: logging.warning( f"IRC room event '{event.type}' fell through, target was from command." ) source = self.source_text(conn, event) args = " ".join(event.arguments) source = self.source_text(conn, event) target = str(event.target) self.send_notice_html(f"<b>{source} {event.type} {target}</b> {args}") @ircroom_event() def on_pass_if(self, conn, event) -> None: self.send_notice(" ".join(event.arguments)) @ircroom_event() def on_pass_or_ignore(self, conn, event) -> None: pass @ircroom_event(target_arg=0) def on_pass0(self, conn, event) -> None: logging.warning( f"IRC room event '{event.type}' fell through, target was '{event.arguments[0]}'." ) self.send_notice(" ".join(event.arguments)) @ircroom_event(target_arg=1) def on_pass1(self, conn, event) -> None: logging.warning( f"IRC room event '{event.type}' fell through, target was '{event.arguments[1]}'." ) self.send_notice(" ".join(event.arguments)) def on_server_message(self, conn, event) -> None: self.send_notice(" ".join(event.arguments)) def on_umodeis(self, conn, event) -> None: self.send_notice(f"Your user mode is: {event.arguments[0]}") def on_umode(self, conn, event) -> None: self.send_notice( f"User mode changed for {event.target}: {event.arguments[0]}") def source_text(self, conn, event) -> str: source = None if event.source is not None: source = str(event.source.nick) if event.source.user is not None and event.source.host is not None: source += f" ({event.source.user}@{event.source.host})" else: source = conn.server return source @ircroom_event() def on_privnotice(self, conn, event) -> None: # show unhandled notices in server room source = self.source_text(conn, event) self.send_notice_html( f"Notice from <b>{source}:</b> {event.arguments[0]}") @ircroom_event() def on_ctcp(self, conn, event) -> None: # show unhandled ctcps in server room source = self.source_text(conn, event) self.send_notice_html( f"<b>{source}</b> requested <b>CTCP {event.arguments[0]}</b> which we ignored" ) def on_welcome(self, conn, event) -> None: self.on_server_message(conn, event) async def later(): await asyncio.sleep(2) if self.autocmd is not None: self.send_notice( "Sending autocmd and waiting a bit before joining channels..." ) self.conn.send_raw(self.autocmd) await asyncio.sleep(4) channels = [] keys = [] for room in self.rooms.values(): if type(room) is ChannelRoom: channels.append(room.name) keys.append(room.key if room.key else "") if len(channels) > 0: self.send_notice(f"Joining channels {', '.join(channels)}") self.conn.join(",".join(channels), ",".join(keys)) asyncio.ensure_future(later()) @ircroom_event() def on_privmsg(self, conn, event) -> bool: # slightly backwards target = event.source.nick.lower() if target not in self.rooms: async def later(): # reuse query command to create a room await self.cmd_query( Namespace(nick=event.source.nick, message=[])) # push the message room = self.rooms[target] room.on_privmsg(conn, event) asyncio.ensure_future(later()) else: room = self.rooms[target] if not room.in_room(self.user_id): asyncio.ensure_future( self.serv.api.post_room_invite(self.rooms[target].id, self.user_id)) @ircroom_event() def on_join(self, conn, event) -> None: target = event.target.lower() logging.debug( f"Handling JOIN to {target} by {event.source.nick} (we are {self.conn.real_nickname})" ) # create a ChannelRoom in response to JOIN if event.source.nick == self.conn.real_nickname and target not in self.rooms: logging.debug( "Pre-flight check for JOIN ok, going to create it...") self.rooms[target] = ChannelRoom.create(self, event.target) # pass this event through self.rooms[target].on_join(conn, event) def on_join_update_host(self, conn, event) -> None: # update for split long if event.source.nick == self.conn.real_nickname and self.real_host != event.source.host: self.real_host = event.source.host logging.debug(f"Self host updated to '{self.real_host}'") def on_quit(self, conn, event) -> None: irc_user_id = self.serv.irc_user_id(self.name, event.source.nick) # leave channels for room in self.rooms.values(): if type(room) is ChannelRoom: if room.in_room(irc_user_id): asyncio.ensure_future( self.serv.api.post_room_leave(room.id, irc_user_id)) def on_nick(self, conn, event) -> None: async def later(): old_irc_user_id = self.serv.irc_user_id(self.name, event.source.nick) new_irc_user_id = await self.serv.ensure_irc_user_id( self.name, event.target) # special case where only cases change, ensure will update displayname if old_irc_user_id == new_irc_user_id: return # leave and join channels for room in self.rooms.values(): if type(room) is ChannelRoom: if room.in_room(old_irc_user_id): # notify mx user about the change room.send_notice("{} is changing nick to {}".format( event.source.nick, event.target)) await self.serv.api.post_room_leave( room.id, old_irc_user_id) await self.serv.api.post_room_invite( room.id, new_irc_user_id) await self.serv.api.post_room_join( room.id, new_irc_user_id) asyncio.ensure_future(later()) def on_nicknameinuse(self, conn, event) -> None: newnick = event.arguments[0] + "_" self.conn.nick(newnick) self.send_notice( f"Nickname {event.arguments[0]} is in use, trying {newnick}") def on_invite(self, conn, event) -> None: self.send_notice_html("<b>{}</b> has invited you to <b>{}</b>".format( event.source.nick, event.arguments[0])) @ircroom_event() def on_kill(self, conn, event) -> None: if event.target == conn.real_nickname: source = self.source_text(conn, event) self.send_notice_html( f"Killed by <b>{source}</b>: {event.arguments[0]}") # do not reconnect after KILL self.connected = False def on_error(self, conn, event) -> None: self.send_notice_html(f"<b>ERROR</b>: {event.target}")
def init(self): self.commands = CommandManager() cmd = CommandParser(prog="NETWORKS", description="list available networks") self.commands.register(cmd, self.cmd_networks) cmd = CommandParser(prog="SERVERS", description="list servers for a network") cmd.add_argument("network", help="network name (see NETWORKS)") self.commands.register(cmd, self.cmd_servers) cmd = CommandParser(prog="OPEN", description="open network for connecting") cmd.add_argument("name", help="network name (see NETWORKS)") cmd.add_argument("--new", action="store_true", help="force open a new network connection") self.commands.register(cmd, self.cmd_open) cmd = CommandParser( prog="STATUS", description="show bridge status", epilog="Note: admins see all users but only their own rooms", ) self.commands.register(cmd, self.cmd_status) cmd = CommandParser( prog="QUIT", description="disconnect from all networks", epilog= ("For quickly leaving all networks and removing configurations in a single command.\n" "\n" "Additionally this will close current DM session with the bridge.\n" ), ) self.commands.register(cmd, self.cmd_quit) if self.serv.is_admin(self.user_id): cmd = CommandParser(prog="MASKS", description="list allow masks") self.commands.register(cmd, self.cmd_masks) cmd = CommandParser( prog="ADDMASK", description="add new allow mask", epilog= ("For anyone else than the owner to use this bridge they need to be allowed to talk with the bridge bot.\n" "This is accomplished by adding an allow mask that determines their permission level when using the bridge.\n" "\n" "Only admins can manage networks, normal users can just connect.\n" ), ) cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) cmd.add_argument("--admin", help="Admin level access", action="store_true") self.commands.register(cmd, self.cmd_addmask) cmd = CommandParser( prog="DELMASK", description="delete allow mask", epilog= ("Note: Removing a mask only prevents starting a new DM with the bridge bot. Use FORGET for ending existing" " sessions."), ) cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) self.commands.register(cmd, self.cmd_delmask) cmd = CommandParser(prog="ADDNETWORK", description="add new network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_addnetwork) cmd = CommandParser(prog="DELNETWORK", description="delete network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_delnetwork) cmd = CommandParser(prog="ADDSERVER", description="add server to a network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) cmd.add_argument("--tls", action="store_true", help="use TLS encryption", default=False) cmd.add_argument( "--tls-insecure", action="store_true", help= "ignore TLS verification errors (hostname, self-signed, expired)", default=False, ) cmd.add_argument("--proxy", help="use a SOCKS proxy (socks5://...)", default=None) self.commands.register(cmd, self.cmd_addserver) cmd = CommandParser(prog="DELSERVER", description="delete server from a network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) self.commands.register(cmd, self.cmd_delserver) cmd = CommandParser( prog="FORGET", description= "remove all connections and configuration of a user", epilog= ("Kills all connections of this user, removes all user set configuration and makes the bridge leave all rooms" " where this user is in.\n" "If the user still has an allow mask they can DM the bridge again to reconfigure and reconnect.\n" "\n" "This is meant as a way to kick users after removing an allow mask or resetting a user after losing access to" " existing account/rooms for any reason.\n"), ) cmd.add_argument("user", help="Matrix ID (eg: @ex-friend:contoso.com)") self.commands.register(cmd, self.cmd_forget) cmd = CommandParser(prog="DISPLAYNAME", description="change bridge displayname") cmd.add_argument("displayname", help="new bridge displayname") self.commands.register(cmd, self.cmd_displayname) cmd = CommandParser(prog="AVATAR", description="change bridge avatar") cmd.add_argument("url", help="new avatar URL (mxc:// format)") self.commands.register(cmd, self.cmd_avatar) cmd = CommandParser( prog="IDENT", description="configure ident replies", epilog= "Note: MXID here is case sensitive, see subcommand help with IDENTCFG SET -h", ) subcmd = cmd.add_subparsers(help="commands", dest="cmd") subcmd.add_parser("list", help="list custom idents (default)") cmd_set = subcmd.add_parser("set", help="set custom ident") cmd_set.add_argument("mxid", help="mxid of the user") cmd_set.add_argument("ident", help="custom ident for the user") cmd_remove = subcmd.add_parser("remove", help="remove custom ident") cmd_remove.add_argument("mxid", help="mxid of the user") self.commands.register(cmd, self.cmd_ident) cmd = CommandParser( prog="SYNC", description="set default IRC member sync mode", epilog="Note: Users can override this per room.", ) group = cmd.add_mutually_exclusive_group() group.add_argument( "--lazy", help="set lazy sync, members are added when they talk", action="store_true") group.add_argument( "--half", help= "set half sync, members are added when they join or talk (default)", action="store_true") group.add_argument( "--full", help="set full sync, members are fully synchronized", action="store_true") self.commands.register(cmd, self.cmd_sync) cmd = CommandParser( prog="MAXLINES", description= "set default maximum number of lines per message until truncation or pastebin", epilog="Note: Users can override this per room.", ) cmd.add_argument("lines", type=int, nargs="?", help="Number of lines") self.commands.register(cmd, self.cmd_maxlines) cmd = CommandParser( prog="PASTEBIN", description= "enable or disable automatic pastebin of long messages by default", epilog="Note: Users can override this per room.", ) cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable pastebin") cmd.add_argument( "--disable", dest="enabled", action="store_false", help="Disable pastebin (messages will be truncated)") cmd.set_defaults(enabled=None) self.commands.register(cmd, self.cmd_pastebin) cmd = CommandParser(prog="MEDIAURL", description="configure media URL for links") cmd.add_argument("url", nargs="?", help="new URL override") cmd.add_argument( "--remove", help="remove URL override (will retry auto-detection)", action="store_true") self.commands.register(cmd, self.cmd_media_url) cmd = CommandParser(prog="VERSION", description="show bridge version") self.commands.register(cmd, self.cmd_version) self.mx_register("m.room.message", self.on_mx_message)
class ControlRoom(Room): commands: CommandManager def init(self): self.commands = CommandManager() cmd = CommandParser(prog="NETWORKS", description="list available networks") self.commands.register(cmd, self.cmd_networks) cmd = CommandParser(prog="SERVERS", description="list servers for a network") cmd.add_argument("network", help="network name (see NETWORKS)") self.commands.register(cmd, self.cmd_servers) cmd = CommandParser(prog="OPEN", description="open network for connecting") cmd.add_argument("name", help="network name (see NETWORKS)") cmd.add_argument("--new", action="store_true", help="force open a new network connection") self.commands.register(cmd, self.cmd_open) cmd = CommandParser( prog="STATUS", description="show bridge status", epilog="Note: admins see all users but only their own rooms", ) self.commands.register(cmd, self.cmd_status) cmd = CommandParser( prog="QUIT", description="disconnect from all networks", epilog= ("For quickly leaving all networks and removing configurations in a single command.\n" "\n" "Additionally this will close current DM session with the bridge.\n" ), ) self.commands.register(cmd, self.cmd_quit) if self.serv.is_admin(self.user_id): cmd = CommandParser(prog="MASKS", description="list allow masks") self.commands.register(cmd, self.cmd_masks) cmd = CommandParser( prog="ADDMASK", description="add new allow mask", epilog= ("For anyone else than the owner to use this bridge they need to be allowed to talk with the bridge bot.\n" "This is accomplished by adding an allow mask that determines their permission level when using the bridge.\n" "\n" "Only admins can manage networks, normal users can just connect.\n" ), ) cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) cmd.add_argument("--admin", help="Admin level access", action="store_true") self.commands.register(cmd, self.cmd_addmask) cmd = CommandParser( prog="DELMASK", description="delete allow mask", epilog= ("Note: Removing a mask only prevents starting a new DM with the bridge bot. Use FORGET for ending existing" " sessions."), ) cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) self.commands.register(cmd, self.cmd_delmask) cmd = CommandParser(prog="ADDNETWORK", description="add new network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_addnetwork) cmd = CommandParser(prog="DELNETWORK", description="delete network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_delnetwork) cmd = CommandParser(prog="ADDSERVER", description="add server to a network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) cmd.add_argument("--tls", action="store_true", help="use TLS encryption", default=False) cmd.add_argument( "--tls-insecure", action="store_true", help= "ignore TLS verification errors (hostname, self-signed, expired)", default=False, ) cmd.add_argument("--proxy", help="use a SOCKS proxy (socks5://...)", default=None) self.commands.register(cmd, self.cmd_addserver) cmd = CommandParser(prog="DELSERVER", description="delete server from a network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) self.commands.register(cmd, self.cmd_delserver) cmd = CommandParser( prog="FORGET", description= "remove all connections and configuration of a user", epilog= ("Kills all connections of this user, removes all user set configuration and makes the bridge leave all rooms" " where this user is in.\n" "If the user still has an allow mask they can DM the bridge again to reconfigure and reconnect.\n" "\n" "This is meant as a way to kick users after removing an allow mask or resetting a user after losing access to" " existing account/rooms for any reason.\n"), ) cmd.add_argument("user", help="Matrix ID (eg: @ex-friend:contoso.com)") self.commands.register(cmd, self.cmd_forget) cmd = CommandParser(prog="DISPLAYNAME", description="change bridge displayname") cmd.add_argument("displayname", help="new bridge displayname") self.commands.register(cmd, self.cmd_displayname) cmd = CommandParser(prog="AVATAR", description="change bridge avatar") cmd.add_argument("url", help="new avatar URL (mxc:// format)") self.commands.register(cmd, self.cmd_avatar) cmd = CommandParser( prog="IDENT", description="configure ident replies", epilog= "Note: MXID here is case sensitive, see subcommand help with IDENTCFG SET -h", ) subcmd = cmd.add_subparsers(help="commands", dest="cmd") subcmd.add_parser("list", help="list custom idents (default)") cmd_set = subcmd.add_parser("set", help="set custom ident") cmd_set.add_argument("mxid", help="mxid of the user") cmd_set.add_argument("ident", help="custom ident for the user") cmd_remove = subcmd.add_parser("remove", help="remove custom ident") cmd_remove.add_argument("mxid", help="mxid of the user") self.commands.register(cmd, self.cmd_ident) cmd = CommandParser( prog="SYNC", description="set default IRC member sync mode", epilog="Note: Users can override this per room.", ) group = cmd.add_mutually_exclusive_group() group.add_argument( "--lazy", help="set lazy sync, members are added when they talk", action="store_true") group.add_argument( "--half", help= "set half sync, members are added when they join or talk (default)", action="store_true") group.add_argument( "--full", help="set full sync, members are fully synchronized", action="store_true") self.commands.register(cmd, self.cmd_sync) cmd = CommandParser( prog="MAXLINES", description= "set default maximum number of lines per message until truncation or pastebin", epilog="Note: Users can override this per room.", ) cmd.add_argument("lines", type=int, nargs="?", help="Number of lines") self.commands.register(cmd, self.cmd_maxlines) cmd = CommandParser( prog="PASTEBIN", description= "enable or disable automatic pastebin of long messages by default", epilog="Note: Users can override this per room.", ) cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable pastebin") cmd.add_argument( "--disable", dest="enabled", action="store_false", help="Disable pastebin (messages will be truncated)") cmd.set_defaults(enabled=None) self.commands.register(cmd, self.cmd_pastebin) cmd = CommandParser(prog="MEDIAURL", description="configure media URL for links") cmd.add_argument("url", nargs="?", help="new URL override") cmd.add_argument( "--remove", help="remove URL override (will retry auto-detection)", action="store_true") self.commands.register(cmd, self.cmd_media_url) cmd = CommandParser(prog="VERSION", description="show bridge version") self.commands.register(cmd, self.cmd_version) self.mx_register("m.room.message", self.on_mx_message) def is_valid(self) -> bool: if self.user_id is None: return False if len(self.members) != 2: return False return True async def show_help(self): self.send_notice_html( f"<b>Howdy, stranger!</b> You have been granted access to the IRC bridge of <b>{self.serv.server_name}</b>." ) try: return await self.commands.trigger("HELP") except CommandParserError as e: return self.send_notice(str(e)) async def on_mx_message(self, event) -> bool: if str(event.content.msgtype ) != "m.text" or event.sender == self.serv.user_id: return # ignore edits if event.content.get_edit(): return try: if event.content.formatted_body: lines = str(await self.parser.parse(event.content.formatted_body )).split("\n") else: lines = event.content.body.split("\n") command = lines.pop(0) tail = "\n".join(lines) if len(lines) > 0 else None await self.commands.trigger(command, tail) except CommandParserError as e: self.send_notice(str(e)) def networks(self): networks = {} for network, config in self.serv.config["networks"].items(): config["name"] = network networks[network.lower()] = config return networks async def cmd_masks(self, args): msg = "Configured masks:\n" for mask, value in self.serv.config["allow"].items(): msg += "\t{} -> {}\n".format(mask, value) self.send_notice(msg) async def cmd_addmask(self, args): masks = self.serv.config["allow"] if args.mask in masks: return self.send_notice("Mask already exists") masks[args.mask] = "admin" if args.admin else "user" await self.serv.save() self.send_notice("Mask added.") async def cmd_delmask(self, args): masks = self.serv.config["allow"] if args.mask not in masks: return self.send_notice("Mask does not exist") del masks[args.mask] await self.serv.save() self.send_notice("Mask removed.") async def cmd_networks(self, args): networks = self.serv.config["networks"] self.send_notice("Configured networks:") for network, data in networks.items(): self.send_notice(f"\t{network} ({len(data['servers'])} servers)") async def cmd_addnetwork(self, args): networks = self.networks() if args.name.lower() in networks: return self.send_notice("Network already exists") self.serv.config["networks"][args.name] = {"servers": []} await self.serv.save() self.send_notice("Network added.") async def cmd_delnetwork(self, args): networks = self.networks() if args.name.lower() not in networks: return self.send_notice("Network does not exist") # FIXME: check if anyone is currently connected # FIXME: if no one is currently connected, leave from all network related rooms del self.serv.config["networks"][args.name] await self.serv.save() return self.send_notice("Network removed.") async def cmd_servers(self, args): networks = self.networks() if args.network.lower() not in networks: return self.send_notice("Network does not exist") network = networks[args.network.lower()] self.send_notice(f"Configured servers for {network['name']}:") for server in network["servers"]: with_tls = "" if server["tls"]: if "tls_insecure" in server and server["tls_insecure"]: with_tls = "with insecure TLS" else: with_tls = "with TLS" proxy = (f" through {server['proxy']}" if "proxy" in server and server["proxy"] is not None and len(server["proxy"]) > 0 else "") self.send_notice( f"\t{server['address']}:{server['port']} {with_tls}{proxy}") async def cmd_addserver(self, args): networks = self.networks() if args.network.lower() not in networks: return self.send_notice("Network does not exist") network = networks[args.network.lower()] address = args.address.lower() for server in network["servers"]: if server["address"] == address and server["port"] == args.port: return self.send_notice("This server already exists.") self.serv.config["networks"][network["name"]]["servers"].append({ "address": address, "port": args.port, "tls": args.tls, "tls_insecure": args.tls_insecure, "proxy": args.proxy, }) await self.serv.save() self.send_notice("Server added.") async def cmd_delserver(self, args): networks = self.networks() if args.network.lower() not in networks: return self.send_notice("Network does not exist") network = networks[args.network.lower()] address = args.address.lower() to_pop = -1 for i, server in enumerate(network["servers"]): if server["address"] == address and server["port"] == args.port: to_pop = i break if to_pop == -1: return self.send_notice("No such server.") self.serv.config["networks"][network["name"]]["servers"].pop(to_pop) await self.serv.save() self.send_notice("Server deleted.") async def cmd_status(self, args): users = set() if self.serv.is_admin(self.user_id): for room in self.serv.find_rooms(): users.add(room.user_id) users = list(users) users.sort() else: users.add(self.user_id) self.send_notice_html(f"I have {len(users)} known users:") for user_id in users: ncontrol = len(self.serv.find_rooms("ControlRoom", user_id)) self.send_notice_html( f"{indent(1)}{user_id} ({ncontrol} open control rooms):") for network in self.serv.find_rooms("NetworkRoom", user_id): connected = "not connected" channels = "not in channels" privates = "not in PMs" plumbs = "not in plumbs" if network.conn and network.conn.connected: user = network.real_user if network.real_user[ 0] != "?" else "?" host = network.real_host if network.real_host[ 0] != "?" else "?" connected = f"connected as {network.conn.real_nickname}!{user}@{host}" nchannels = 0 nprivates = 0 nplumbs = 0 for room in network.rooms.values(): if type(room).__name__ == "PrivateRoom": nprivates += 1 if type(room).__name__ == "ChannelRoom": nchannels += 1 if type(room).__name__ == "PlumbedRoom": nplumbs += 1 if nprivates > 0: privates = f"in {nprivates} PMs" if nchannels > 0: channels = f"in {nchannels} channels" if nplumbs > 0: plumbs = f"in {nplumbs} plumbs" self.send_notice_html( f"{indent(2)}{network.name}, {connected}, {channels}, {privates}, {plumbs}" ) if self.user_id == user_id: for room in network.rooms.values(): join = "" if not room.in_room(user_id): join = " (you have not joined this room)" # ensure the user invite is valid await self.az.intent.invite_user( room.id, self.user_id) self.send_notice_html( f'{indent(3)}<a href="https://matrix.to/#/{escape(room.id)}">{escape(room.name)}</a>{join}' ) async def cmd_forget(self, args): if args.user == self.user_id: return self.send_notice("I can't forget you, silly!") rooms = self.serv.find_rooms(None, args.user) if len(rooms) == 0: return self.send_notice( "No such user. See STATUS for list of users.") # disconnect each network room in first pass for room in rooms: if type(room) == NetworkRoom and room.conn and room.conn.connected: self.send_notice( f"Disconnecting {args.user} from {room.name}...") await room.cmd_disconnect(Namespace()) self.send_notice( f"Leaving all {len(rooms)} rooms {args.user} was in...") # then just forget everything for room in rooms: self.serv.unregister_room(room.id) try: await self.az.intent.leave_room(room.id) except MatrixRequestError: pass try: await self.az.intent.forget_room(room.id) except MatrixRequestError: pass self.send_notice(f"Done, I have forgotten about {args.user}") async def cmd_displayname(self, args): try: await self.az.intent.set_displayname(args.displayname) except MatrixRequestError as e: self.send_notice(f"Failed to set displayname: {str(e)}") async def cmd_avatar(self, args): try: await self.az.intent.set_avatar_url(args.url) except MatrixRequestError as e: self.send_notice(f"Failed to set avatar: {str(e)}") async def cmd_ident(self, args): idents = self.serv.config["idents"] if args.cmd == "list" or args.cmd is None: self.send_notice("Configured custom idents:") for mxid, ident in idents.items(): self.send_notice(f"\t{mxid} -> {ident}") elif args.cmd == "set": if not re.match(r"^[a-z][-a-z0-9]*$", args.ident): self.send_notice(f"Invalid ident string: {args.ident}") self.send_notice( "Must be lowercase, start with a letter, can contain dashes, letters and numbers." ) else: idents[args.mxid] = args.ident self.send_notice( f"Set custom ident for {args.mxid} to {args.ident}") await self.serv.save() elif args.cmd == "remove": if args.mxid in idents: del idents[args.mxid] self.send_notice(f"Removed custom ident for {args.mxid}") await self.serv.save() else: self.send_notice(f"No custom ident for {args.mxid}") async def cmd_sync(self, args): if args.lazy: self.serv.config["member_sync"] = "lazy" await self.serv.save() elif args.half: self.serv.config["member_sync"] = "half" await self.serv.save() elif args.full: self.serv.config["member_sync"] = "full" await self.serv.save() self.send_notice( f"Member sync is set to {self.serv.config['member_sync']}") async def cmd_media_url(self, args): if args.remove: self.serv.config["media_url"] = None await self.serv.save() self.serv.endpoint = await self.serv.detect_public_endpoint() elif args.url: parsed = urlparse(args.url) if parsed.scheme in [ "http", "https" ] and not parsed.params and not parsed.query and not parsed.fragment: self.serv.config["media_url"] = args.url await self.serv.save() self.serv.endpoint = args.url else: self.send_notice(f"Invalid media URL format: {args.url}") return self.send_notice( f"Media URL override is set to {self.serv.config['media_url']}") self.send_notice(f"Current active media URL: {self.serv.endpoint}") async def cmd_maxlines(self, args): if args.lines is not None: self.serv.config["max_lines"] = args.lines await self.serv.save() self.send_notice( f"Max lines default is {self.serv.config['max_lines']}") async def cmd_pastebin(self, args): if args.enabled is not None: self.serv.config["use_pastebin"] = args.enabled await self.serv.save() self.send_notice( f"Pastebin is {'enabled' if self.serv.config['use_pastebin'] else 'disabled'} by default" ) async def cmd_open(self, args): networks = self.networks() name = args.name.lower() if name not in networks: return self.send_notice("Network does not exist") network = networks[name] found = 0 for room in self.serv.find_rooms(NetworkRoom, self.user_id): if room.name == network["name"]: found += 1 if not args.new: if self.user_id not in room.members: self.send_notice( f"Inviting back to {room.name} ({room.id})") await self.az.intent.invite_user(room.id, self.user_id) else: self.send_notice( f"You are already in {room.name} ({room.id})") # if we found at least one network room, no need to create unless forced if found > 0 and not args.new: return name = network[ "name"] if found == 0 else f"{network['name']} {found + 1}" self.send_notice(f"You have been invited to {name}") await NetworkRoom.create(self.serv, network["name"], self.user_id, name) async def cmd_quit(self, args): rooms = self.serv.find_rooms(None, self.user_id) # disconnect each network room in first pass for room in rooms: if type(room) == NetworkRoom and room.conn and room.conn.connected: self.send_notice(f"Disconnecting from {room.name}...") await room.cmd_disconnect(Namespace()) self.send_notice("Closing all channels and private messages...") # then just forget everything for room in rooms: if room.id == self.id: continue self.serv.unregister_room(room.id) try: await self.az.intent.leave_room(room.id) except MatrixRequestError: pass try: await self.az.intent.forget_room(room.id) except MatrixRequestError: pass self.send_notice("Goodbye!") await asyncio.sleep(1) raise RoomInvalidError("Leaving") async def cmd_version(self, args): self.send_notice(f"heisenbridge v{__version__}")
class ControlRoom(Room): commands: CommandManager def init(self): self.commands = CommandManager() cmd = CommandParser(prog="NETWORKS", description="List networks") self.commands.register(cmd, self.cmd_networks) cmd = CommandParser(prog="SERVERS", description="List servers") cmd.add_argument("network", help="network name") self.commands.register(cmd, self.cmd_servers) cmd = CommandParser(prog="OPEN", description="Open network room to connect") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_open) if self.serv.is_admin(self.user_id): cmd = CommandParser(prog="MASKS", description="List allow masks") self.commands.register(cmd, self.cmd_masks) cmd = CommandParser(prog="ADDMASK", description="Add allow mask") cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) cmd.add_argument("--admin", help="Admin level access", action="store_true") self.commands.register(cmd, self.cmd_addmask) cmd = CommandParser(prog="DELMASK", description="Remove allow mask") cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) self.commands.register(cmd, self.cmd_delmask) cmd = CommandParser(prog="ADDNETWORK", description="Add network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_addnetwork) cmd = CommandParser(prog="DELNETWORK", description="Delete network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_delnetwork) cmd = CommandParser(prog="ADDSERVER", description="Add server to network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) cmd.add_argument("--tls", action="store_true", help="use TLS encryption", default=False) self.commands.register(cmd, self.cmd_addserver) cmd = CommandParser(prog="DELSERVER", description="Delete server from network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) self.commands.register(cmd, self.cmd_delserver) self.mx_register("m.room.message", self.on_mx_message) def is_valid(self) -> bool: if self.user_id is None: return False if len(self.members) != 2: return False return True async def show_help(self): self.send_notice_html( f"<b>Howdy, stranger!</b> You have been granted access to the IRC bridge of <b>{self.serv.server_name}</b>." ) try: return await self.commands.trigger("HELP") except CommandParserError as e: return self.send_notice(str(e)) async def on_mx_message(self, event) -> None: if event["content"]["msgtype"] != "m.text" or event[ "user_id"] == self.serv.user_id: return True try: return await self.commands.trigger(event["content"]["body"]) except CommandParserError as e: return self.send_notice(str(e)) def networks(self): networks = {} for network, config in self.serv.config["networks"].items(): config["name"] = network networks[network.lower()] = config return networks async def cmd_masks(self, args): msg = "Configured masks:\n" for mask, value in self.serv.config["allow"].items(): msg += "\t{} -> {}\n".format(mask, value) self.send_notice(msg) async def cmd_addmask(self, args): masks = self.serv.config["allow"] if args.mask in masks: return self.send_notice("Mask already exists") masks[args.mask] = "admin" if args.admin else "user" await self.serv.save() self.send_notice("Mask added.") async def cmd_delmask(self, args): masks = self.serv.config["allow"] if args.mask not in masks: return self.send_notice("Mask does not exist") del masks[args.mask] await self.serv.save() self.send_notice("Mask removed.") async def cmd_networks(self, args): networks = self.serv.config["networks"] self.send_notice("Configured networks:") for network, data in networks.items(): self.send_notice(f"\t{network} ({len(data['servers'])} servers)") async def cmd_addnetwork(self, args): networks = self.networks() if args.name.lower() in networks: return self.send_notice("Network already exists") self.serv.config["networks"][args.name] = {"servers": []} await self.serv.save() self.send_notice("Network added.") async def cmd_delnetwork(self, args): networks = self.networks() if args.name.lower() not in networks: return self.send_notice("Network does not exist") # FIXME: check if anyone is currently connected # FIXME: if no one is currently connected, leave from all network related rooms del self.serv.config["networks"][args.name] await self.serv.save() return self.send_notice("Network removed.") async def cmd_servers(self, args): networks = self.networks() if args.network.lower() not in networks: return self.send_notice("Network does not exist") network = networks[args.network.lower()] self.send_notice(f"Configured servers for {network['name']}:") for server in network["servers"]: self.send_notice( f"\t{server['address']}:{server['port']} {'with TLS' if server['tls'] else ''}" ) async def cmd_addserver(self, args): networks = self.networks() if args.network.lower() not in networks: return self.send_notice("Network does not exist") network = networks[args.network.lower()] address = args.address.lower() for server in network["servers"]: if server["address"] == address and server["port"] == args.port: return self.send_notice("This server already exists.") self.serv.config["networks"][network["name"]]["servers"].append({ "address": address, "port": args.port, "tls": args.tls }) await self.serv.save() self.send_notice("Server added.") async def cmd_delserver(self, args): networks = self.networks() if args.network.lower() not in networks: return self.send_notice("Network does not exist") network = networks[args.network.lower()] address = args.address.lower() to_pop = -1 for i, server in enumerate(network["servers"]): if server["address"] == address and server["port"] == args.port: to_pop = i break if to_pop == -1: return self.send_notice("No such server.") self.serv.config["networks"][network["name"]]["servers"].pop(to_pop) await self.serv.save() self.send_notice("Server deleted.") async def cmd_open(self, args): networks = self.networks() name = args.name.lower() if name not in networks: return self.send_notice("Network does not exist") network = networks[name] for room in self.serv.find_rooms(NetworkRoom, self.user_id): if room.name == network["name"]: if self.user_id not in room.members: self.send_notice(f"Inviting back to {room.name}") await self.serv.api.post_room_invite(room.id, self.user_id) else: self.send_notice(f"You are already in {room.name}") return self.send_notice(f"You have been invited to {network['name']}") await NetworkRoom.create(self.serv, network["name"], self.user_id)
def init(self): self.commands = CommandManager() cmd = CommandParser(prog="NETWORKS", description="List networks") self.commands.register(cmd, self.cmd_networks) cmd = CommandParser(prog="SERVERS", description="List servers") cmd.add_argument("network", help="network name") self.commands.register(cmd, self.cmd_servers) cmd = CommandParser(prog="OPEN", description="Open network room to connect") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_open) if self.serv.is_admin(self.user_id): cmd = CommandParser(prog="MASKS", description="List allow masks") self.commands.register(cmd, self.cmd_masks) cmd = CommandParser(prog="ADDMASK", description="Add allow mask") cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) cmd.add_argument("--admin", help="Admin level access", action="store_true") self.commands.register(cmd, self.cmd_addmask) cmd = CommandParser(prog="DELMASK", description="Remove allow mask") cmd.add_argument( "mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)" ) self.commands.register(cmd, self.cmd_delmask) cmd = CommandParser(prog="ADDNETWORK", description="Add network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_addnetwork) cmd = CommandParser(prog="DELNETWORK", description="Delete network") cmd.add_argument("name", help="network name") self.commands.register(cmd, self.cmd_delnetwork) cmd = CommandParser(prog="ADDSERVER", description="Add server to network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) cmd.add_argument("--tls", action="store_true", help="use TLS encryption", default=False) self.commands.register(cmd, self.cmd_addserver) cmd = CommandParser(prog="DELSERVER", description="Delete server from network") cmd.add_argument("network", help="network name") cmd.add_argument("address", help="server address") cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) self.commands.register(cmd, self.cmd_delserver) self.mx_register("m.room.message", self.on_mx_message)