#!/usr/bin/perl -w use strict; use warnings; use LWP::UserAgent; use HTTP::Request; use JSON qw(from_json to_json); use Getopt::Long; use Config::Simple; use File::HomeDir; use File::MimeInfo; use File::Basename; use URI::Escape; use Path::Tiny; use Term::ReadKey; use Hash::Merge::Simple qw(merge); use Scalar::Util qw(looks_like_number); our $opt; GetOptions( "user=s" => \$opt->{user}, "password=s" => \$opt->{password}, "access_token=s" => \$opt->{access_token}, "server=s" => \$opt->{server}, "room=s" => \$opt->{room}, "message=s" => \$opt->{message}, "files=s" => \$opt->{file}, "debug" => \$opt->{debug}, "action=s" => \$opt->{action}, "send-msg" => \$opt->{'send-msg'}, "send-notice" => \$opt->{'send-notice'}, "send-file" => \$opt->{'send-file'}, "create-room" => \$opt->{'create-room'}, "modify-room" => \$opt->{'modify-room'}, "delete-room-alias" => \$opt->{'delete-room-alias'}, "get-access-token" => \$opt->{'get-access-token'}, "get-room-list" => \$opt->{'get-room-list'}, "get-room-id" => \$opt->{'get-room-id'}, "conf=s" => \$opt->{conf}, "invite=s@" => \$opt->{invite}, "name=s" => \$opt->{name}, "alias=s" => \$opt->{alias}, "topic=s" => \$opt->{topic}, "join_rules=s" => \$opt->{join_rules}, "federation!" => \$opt->{federation}, "perm=s@" => \$opt->{perm}, "perm_user=s@" => \$opt->{perm_user}, "perm_event=s@" => \$opt->{perm_user}, "perm_reset" => \$opt->{perm_reset} ); if (-e File::HomeDir->my_home . "/.patrixrc" && !$opt->{conf}){ $opt->{conf} = File::HomeDir->my_home . "/.patrixrc"; debug("Using default config file $opt->{conf}"); } if ($opt->{conf} && -e $opt->{conf}){ read_conf(); } # alias for --action=foo is --foo foreach my $action (qw(send-msg send-notice send-file create-room modify-room delete-room-alias get-access-token get-room-list get-room-id)){ if ($opt->{$action}){ $opt->{action} = $action; last; } } my $lwp = LWP::UserAgent->new; my $stdin = 0; if (!-t STDIN){ debug("Reading data from stdin"); $stdin = 1; } # Set defaults $opt->{server} //= 'matrix.org'; $opt->{action} //= 'send-msg'; $opt->{federation} //= 1; $opt->{server} = 'https://' . $opt->{server} unless ($opt->{server} =~ m|https?://|); # If the given room starts with #, then it's an alias # Lets resolve this to the room ID if ($opt->{room} && $opt->{room} =~ m/^#/){ $opt->{room} = room_alias_to_id($opt->{room}); debug('Room ID is ' . $opt->{room}); } # Check we have all the options we need if (!$opt->{access_token} && !$opt->{user}){ die "You need to provide a valid user and password or a valid access_token\n\n"; } elsif (!$opt->{access_token} && !$opt->{user}){ die "Test: You need to provide either an access token or a valid user and password\n\n"; } if ($opt->{action} eq 'send-msg' && (!$opt->{room} || (!$opt->{message} && !$stdin))){ die "You need to provide a room ID and a message\n\n"; } if ($opt->{action} eq 'send-file' && (!$opt->{room} || !$opt->{file})){ die "You need to provide a room ID and a file to send\n\n"; } if ($opt->{action} eq 'send-file' && $opt->{file} && !-e $opt->{file}){ die "File $opt->{file} not found\n\n"; } if ($opt->{action} eq 'modify-room' && !$opt->{room}){ die "You need to specify the room to modify\n\n"; } if ($opt->{action} =~ m/^(remove|delete|del)\-room\-alias$/ and !$opt->{alias}){ die "You must specify the alias to remove\n\n"; } # Print debug info if debug is enabled sub debug { my $msg = shift; print "$msg\n\n" if $opt->{debug}; } # Resolve a room alias to a room ID sub room_alias_to_id { my $alias = shift; debug("Looking $opt->{room} room ID"); my $uri = $opt->{server} . '/_matrix/client/r0/directory/room/' . uri_escape($alias); my $resp = send_request({ method => 'GET', uri => $uri, }); die "Error lokking up for the room ID\n" unless ($resp->is_success); return from_json($resp->decoded_content)->{room_id}; } # Send a request to Matrix server and return the raw response sub send_request { my $param = shift; $param->{method} ||= 'POST'; $param->{content_type} ||= 'application/json'; $param->{content} ||= to_json({}); die "Missing an URI" unless $param->{uri}; my $req = HTTP::Request->new( $param->{method}, $param->{uri} ); $req->header('Content-Type' => $param->{content_type}); $req->content($param->{content}); my $resp = $lwp->request( $req ); debug("Server responded:\n" . to_json(from_json($resp->decoded_content), { pretty => 1 })); return $resp; } # Load values from the config file if it exists sub read_conf { my $cfg = Config::Simple->new; $cfg->read($opt->{conf}); foreach my $param(keys %{$opt}){ if ($cfg->param('default.' . $param) && !$opt->{$param}){ $opt->{$param} = $cfg->param('default.' . $param) } } } # Submit user and password the the HS and obtain an access_token sub login { debug("Trying to login on $opt->{server} as $opt->{user}"); my $uri = $opt->{server} . '/_matrix/client/r0/login'; my $json = { type => 'm.login.password', user => $opt->{user}, password => $opt->{password} }; my $resp = send_request({ uri => $uri, content => to_json($json) }); die "Error login in, please check your credentials\n" unless ($resp->is_success); # Set the access token $opt->{access_token} = from_json($resp->decoded_content)->{access_token}; die "No access token in server response\n" if !$opt->{access_token}; } # Invalidate the access_token sub logout { debug("Trying to logout"); my $uri = $opt->{server} . '/_matrix/client/r0/logout?access_token=' . $opt->{access_token}; my $resp = send_request({ uri => $uri }); die "Error login out\n" unless ($resp->is_success); } # Join the specified room, before we can send anything sub join_room { debug("Trying to join room $opt->{room}"); # Room must be escaped. if not and room is an alias, it'll start with # so the access_token won't be sent my $uri = $opt->{server} . '/_matrix/client/r0/join/' . uri_escape( $opt->{room} ) . '?access_token=' . $opt->{access_token}; my $resp = send_request({ uri => $uri }); die "Error joining room $opt->{room}\n" unless ($resp->is_success); } # Retrieve the actual permissions for a room sub get_room_permissions { debug('Getting actual room state'); my $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.power_levels?access_token=' . $opt->{access_token}; my $resp = send_request({ method => 'GET', uri => $uri }); die "Error joining room $opt->{room}\n" unless ($resp->is_success); return from_json($resp->decoded_content); } # Return the user ID of the operator sub who_am_i { # We could get user_id if we login with user/pass but what if we use an access token ? # Lets just build it manually for now my $server = $opt->{server}; $server =~ s|^https?://||; return '@' . $opt->{user} .':' . $server; } # Send a text message (either message or notice as both are similar) sub send_msg { my $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/send/m.room.message?access_token=' . $opt->{access_token}; # Ignore --message if reading from stdin if ($stdin){ $opt->{message} = ''; $opt->{message} .= $_ while (); } my $json = { msgtype => ($opt->{action} eq 'send-notice') ? 'm.notice' : 'm.text', body => $opt->{message} }; my $resp = send_request({ uri => $uri, content => to_json($json) }); die "Error sending message to $opt->{room}\n" unless ($resp->is_success); } # Send a file to the room sub send_file { # Sending a file is a 2 steps operation. First we need to upload the file to the media store # And then we post the uri on the room debug("Uploading file $opt->{file} to the media store"); my $uri = $opt->{server} . '/_matrix/media/v1/upload?access_token=' . $opt->{access_token} . '&filename=' . basename($opt->{file}); my $resp = send_request({ uri => $uri, content_type => mimetype $opt->{file}, content => path( $opt->{file} )->slurp_raw }); debug("File upload response is\n" . to_json(from_json($resp->decoded_content), { pretty => 1 })); die "Error uploading file\n" unless ($resp->is_success); # If everything went well, the server replied with the URI of our file, which we can # now post on the room my $file_uri = from_json($resp->decoded_content)->{content_uri}; die "Server did not sent the file URI\n" unless ($file_uri); debug("File uploaded, with the URI $file_uri\nNow Sending the file link to the room $opt->{room}"); # Now lets post a new message with the URI of the file $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/send/m.room.message?access_token=' . $opt->{access_token}; my $json = { msgtype => 'm.file', body => basename($opt->{file}), filename => basename($opt->{file}), info => { mimetype => mimetype $opt->{file}, size => (stat $opt->{file})[7] }, url => $file_uri }; $resp = send_request({ uri => $uri, content => to_json($json) }); die "Error posting file link on room $opt->{room}\n" unless ($resp->is_success); } # List public rooms # Note that there's no pagination handling yet, so you might not have all the results sub list_room { debug("Fetching list of public rooms on $opt->{server}"); my $uri = $opt->{server} . '/_matrix/client/r0/publicRooms?access_token=' . $opt->{access_token}; my $resp = send_request({ uri => $uri, }); die "Error joining room $opt->{room}\n" unless ($resp->is_success); # TODO: Handle pagination debug("List rooms response is\n" . to_json(from_json($resp->decoded_content), { pretty => 1 })); print "Existing Rooms:\n"; foreach (@{from_json($resp->decoded_content)->{chunk}}){ print " * " . $_->{room_id}; print ' (' . $_->{canonical_alias} . ')' if (defined $_->{canonical_alias}); print "\n"; } } # Create a new room sub create_room { debug("Creating a new room on $opt->{server}"); my $uri = $opt->{server} . '/_matrix/client/r0/createRoom?access_token=' . $opt->{access_token}; my $json = {}; $json->{room_alias_name} = $opt->{alias} if $opt->{alias}; $json->{topic} = $opt->{topic} if $opt->{topic}; $json->{name} = $opt->{name} if $opt->{name}; $json->{invite} = $opt->{invite} if $opt->{invite}; $json->{creation_content}->{'m.federate'} = $opt->{federation}; my $resp = send_request({ uri => $uri, content => to_json($json) }); die "Error creating room on $opt->{server}\n" unless ($resp->is_success); my $room_id = from_json($resp->decoded_content)->{room_id}; print "$room_id\n"; } # Modify an existing room sub modify_room { debug("Modifying room $opt->{room} on $opt->{server}"); my ($uri,$req,$json,$resp); # A new alias should be added if ($opt->{alias}){ debug('Adding ' . $opt->{alias} . ' as a room alias'); $uri = $opt->{server} . '/_matrix/client/r0/directory/room/' . uri_escape($opt->{alias}) . '?access_token=' . $opt->{access_token}; $json = { room_id => $opt->{room} }; $resp = send_request({ method => 'PUT', uri => $uri, content => to_json($json) }); die "Error adding new alias $opt->{alias} for room $opt->{room} on server $opt->{server}\n" unless ($resp->is_success); } # The name of the room is being updated if ($opt->{name}){ debug('Changing the room name to ' . $opt->{name}); $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.name?access_token=' . $opt->{access_token}; $json = { name => $opt->{name} }; $resp = send_request({ method => 'PUT', uri => $uri, content => to_json($json) }); die "Error changing name of room $opt->{room}\n" unless ($resp->is_success); } # The topic is being updated if ($opt->{topic}){ debug('Changing the room topic to ' . $opt->{topic}); $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.topic?access_token=' . $opt->{access_token}; $json = { topic => $opt->{topic} }; $resp = send_request({ method => 'PUT', uri => $uri, content => to_json($json) }); die "Error changing topic of room $opt->{room}\n" unless ($resp->is_success); } # Changing joining rules if ($opt->{join_rules}){ debug('Changing the joining rules to '. $opt->{join_rules}); $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.join_rules?access_token=' . $opt->{access_token}; $json = { join_rules => $opt->{join_rules} }; $resp = send_request({ method => 'PUT', uri => $uri, content => to_json($json) }); die "Error changing joining rules of room $opt->{room}\n" unless ($resp->is_success); } # Permissions modification if ($opt->{perm} || $opt->{perm_user} || $opt->{perm_event} || $opt->{perm_reset}){ debug('Changing permissions for the room'); my $current_perm = get_room_permissions(); # If we asked to reset the permission if ($opt->{perm_reset}){ my $operator = who_am_i(); my $reset_perm = { events => { "m.room.avatar" => 50, "m.room.canonical_alias" => 50, "m.room.name" => 50, "m.room.power_levels" => 100, "m.room.history_visibility" => 100 }, }; # Ensure we keep at least the permission of the operating user # Note that we must also keep the permission of anyone who has at least the same level # of privilege, or the operation will be forbidden foreach my $user (keys %{$current_perm->{users}}){ if (looks_like_number($current_perm->{users}->{$user}) && $current_perm->{users}->{$user} >= $current_perm->{users}->{$operator}){ debug("Keeping permission of $user because it has at least the same privileges " . "($current_perm->{users}->{$user} vs $current_perm->{users}->{$operator})"); $reset_perm->{users}->{$user} = $current_perm->{users}->{$user}; } } $current_perm = $reset_perm; } my $new_perm = {}; if ($opt->{perm}){ foreach my $perm (@{$opt->{perm}}){ my ($key,$val) = split (/\s*=\s*/, $perm); $new_perm->{$key} = $val; } } if ($opt->{perm_user}){ foreach my $perm (@{$opt->{perm_user}}){ my ($key,$val) = split (/\s*=\s*/, $perm); # Prevent the operating user to downgrade its own permissions next if ($key eq $opt->{user}); $new_perm->{users}->{$key} = $val; } } if ($opt->{perm_event}){ foreach my $perm (@{$opt->{perm_event}}){ my ($key,$val) = split (/\s*=\s*/, $perm); $new_perm->{events}->{$key} = $val; } } my $perm = merge($current_perm, $new_perm); debug("New permissions for this room will be:\n" . to_json($perm, { pretty => 1 })); $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/state/m.room.power_levels?access_token=' . $opt->{access_token}; $resp = send_request({ method => 'PUT', uri => $uri, content => to_json($perm) }); die "Error changing permissions for room $opt->{room}\n" unless ($resp->is_success); } # New invitees should be added if ($opt->{invite}){ debug('Inviting ' . join(',', @{$opt->{invite}}) . ' to join the room'); $uri = $opt->{server} . '/_matrix/client/r0/rooms/' . $opt->{room} . '/invite?access_token=' . $opt->{access_token}; foreach my $invite (@{$opt->{invite}}){ $json = { user_id => $invite }; $resp = send_request({ uri => $uri, content => to_json($json) }); unless ($resp->is_success){ my $error = from_json($resp->decoded_content); if ($error->{error} eq $invite . ' is already in the room.' && $error->{errcode} eq 'M_FORBIDDEN'){ debug($invite . ' has already been invited in this room, ignoring'); } else{ die "Error inviting user $invite in room $opt->{room}\n"; } } } } } sub del_room_alias { debug("Removing room alias $opt->{alias}"); my $uri = $opt->{server} . "/_matrix/client/r0/directory/room/" . uri_escape($opt->{alias}) . '?access_token=' . $opt->{access_token}; my $resp = send_request({ method => 'DELETE', uri => $uri }); die "Error removing the alias\n" unless ($resp->is_success); } # If we ask for a new access token, then we must login, and ignore any # access_token from the config file $opt->{access_token} = undef if ($opt->{action} eq 'get-access-token'); # No password on the command line or the conf file # And no access token # Prompt to type the password if (!$opt->{access_token} && $opt->{user} && !$opt->{password}){ ReadMode('noecho'); print "Password: "; $opt->{password} = ReadLine(0); $opt->{password} =~ s/\R\z//; ReadMode('restore'); print "\n"; } # Should we logout at the end ? Only if we used login and pass # If we used an access_token, we don't want it to be invalidated my $must_logout = ($opt->{access_token} || $opt->{action} eq 'get-access-token') ? 0 : 1; # If we don't have an access token, we must get one now if (!$opt->{access_token}){ login(); } if ($opt->{action} eq 'get-access-token'){ print $opt->{access_token} . "\n"; } elsif ($opt->{action} eq 'get-room-list'){ list_room(); } elsif ($opt->{action} eq 'get-room-id'){ print room_alias_to_id($opt->{room}) . "\n"; } elsif ($opt->{action} =~ m/^send\-(msg|message|notice)$/){ join_room(); send_msg(); } elsif ($opt->{action} eq 'send-file'){ join_room(); send_file(); } elsif ($opt->{action} eq 'create-room'){ create_room(); } elsif ($opt->{action} eq 'modify-room'){ modify_room(); } elsif ($opt->{action} =~ m/^(remove|delete|del)\-room\-alias$/){ del_room_alias(); } logout() if $must_logout; exit(0);