#!/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 File::Spec; use URI::Escape; 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|access-token|token=s" => \$opt->{access_token}, "server=s" => \$opt->{server}, "proxy=s" => \$opt->{proxy}, "room=s" => \$opt->{room}, "message|msg=s" => \$opt->{message}, "files=s@" => \$opt->{file}, "debug" => \$opt->{debug}, "action=s" => \$opt->{action}, "send-msg|send-message" => \$opt->{'send-msg'}, "send-notice" => \$opt->{'send-notice'}, "send-code" => \$opt->{'send-code'}, "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'}, "setup" => \$opt->{setup}, "config=s" => \$opt->{conf}, "invite=s@" => \$opt->{invite}, "name=s" => \$opt->{name}, "alias=s" => \$opt->{alias}, "topic=s" => \$opt->{topic}, "join_rules|join-rules=s" => \$opt->{join_rules}, "permision=s@" => \$opt->{perm}, "perm_user|user-permission=s@" => \$opt->{perm_user}, "perm_event|event-permission=s@" => \$opt->{perm_user}, "perm_reset|reset-permission" => \$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 my @actions = qw( send-msg send-notice send-code send-file create-room modify-room delete-room-alias get-access-token get-room-list get-room-id setup ); foreach my $action (@actions){ if ($opt->{$action}){ $opt->{action} = $action; last; } } my $lwp = LWP::UserAgent->new; # If a proxy is specified then use it. Else, try to get global one if ($opt->{proxy}){ $lwp->proxy(['https'], $opt->{proxy}); } else{ $lwp->env_proxy; } my $stdin = 0; if (!-t STDIN){ debug("Reading data from stdin"); $stdin = 1; } foreach (@{$opt->{file}}){ # Handle ~ $_ =~ s/^~(\w*)/(getpwnam( $1 || $ENV{USER}))[7]/e; # Convert to absolute path $_ = File::Spec->rel2abs($_); } # Set defaults sub set_defaults { $opt->{action} //= 'send-msg'; $opt->{server} = 'https://' . $opt->{server} unless ($opt->{server} =~ m|https?://|); } # 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 during room ID lookup\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; } # Read the content of a file sub slurp { my $file = shift; open my $f, '<', $file or die; local $/ = undef; my $bytes = <$f>; close $f; return $bytes; } # 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?://||; $server =~ s|/$||; 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} }; # If we send code, we have to format it correctly if ($opt->{action} eq 'send-code'){ $json->{formatted_body} = '
' . $opt->{message} . '
'; $json->{format} = 'org.matrix.custom.html'; } 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 { my $file = shift; # 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 $file to the media store"); my $uri = $opt->{server} . '/_matrix/media/v1/upload?access_token=' . $opt->{access_token} . '&filename=' . basename($file); my $mime = mimetype($file); my $resp = send_request({ uri => $uri, content_type => $mime, content => slurp($file) }); 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($file), filename => basename($file), info => { mimetype => mimetype $file, size => (stat $file)[7] }, url => $file_uri }; # If the file is an image, audio or video, send it as such if ($mime =~ m/^image/){ $json->{msgtype} = 'm.image'; } elsif ($mime =~ /^audio/){ $json->{msgtype} = 'm.audio'; } elsif ($mime =~ /^video/){ $json->{msgtype} = 'm.video'; } $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 = {}; my $resp = send_request({ uri => $uri, content => to_json($json) }); die "Error creating room on $opt->{server}\n" unless ($resp->is_success); $opt->{room} = from_json($resp->decoded_content)->{room_id}; # Now configure the room modify_room(); print "$opt->{room}\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); } # Write settings in a config file sub setup { $opt->{conf} //= File::HomeDir->my_home . "/.patrixrc"; my $cfg = Config::Simple->new(syntax => 'ini'); foreach my $param (qw(access_token server room)){ $cfg->param($param, $opt->{$param}) if ($opt->{$param}); } debug("Writing config file $opt->{conf}"); $cfg->save($opt->{conf}); } # If server is not specified, ask for it if (!$opt->{server}){ print "Matrix server: "; $opt->{server} = ReadLine(0); $opt->{server} =~ s/\R\z//; } # If not using an access token # Prompt for the user ID and password # if not provided on the command line or the config file if (!$opt->{access_token}){ if (!$opt->{user}){ print "Matrix ID: "; $opt->{user} = ReadLine(0); $opt->{user} =~ s/\R\z//; } if (!$opt->{password}){ ReadMode('noecho'); print "Password: "; $opt->{password} = ReadLine(0); $opt->{password} =~ s/\R\z//; ReadMode('restore'); print "\n"; } } # Set defaults values set_defaults(); # 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}); } # 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'); # 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' || $opt->{action} eq 'setup')) ? 0 : 1; # If we don't have an access token, we must get one now if (!$opt->{access_token}){ login(); } if ($opt->{action} eq 'setup'){ setup(); } 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 $opt->{room} . "\n"; } elsif ($opt->{action} =~ m/^send\-(msg|message|notice|code)$/){ join_room(); send_msg(); } elsif ($opt->{action} eq 'send-file'){ join_room(); send_file($_) foreach (@{$opt->{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);