patrix is a simple perl based command line client for Matrix
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

380 lines
12 KiB

#!/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;
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},
"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}
);
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();
}
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;
# Prompt to enter 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";
}
# Check we have all the options we need
if ($opt->{action} eq 'get-access-token' && (!$opt->{user} || !$opt->{password})){
die "You need to provide a valid user and password to get an access token\n\n";
}
elsif (!$opt->{access_token} && (!$opt->{user} || !$opt->{password})){
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";
}
$opt->{server} = 'https://' . $opt->{server} unless ($opt->{server} =~ m|https?://|);
my $lwp = LWP::UserAgent->new;
# Print debug info if debug is enabled
sub debug {
my $msg = shift;
print "$msg\n\n" if $opt->{debug};
}
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);
# Resolve room -> room_id if joined by alias
my $room_id = from_json($resp->decoded_content)->{room_id};
# TODO: Provide a generic Alias -> ID resolver
$opt->{room} = $room_id if $room_id;
}
# 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 (<STDIN>);
}
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);
}
# 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";
}
}
}
}
}
# 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}) ? 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} =~ 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();
}
logout() if $must_logout;
exit(0);