mirror of https://github.com/dani/patrix.git
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.
556 lines
19 KiB
556 lines
19 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 File::Spec;
|
|
use URI::Escape;
|
|
use Term::ReadKey;
|
|
use Hash::Merge::Simple qw(merge);
|
|
use Scalar::Util qw(looks_like_number);
|
|
use Image::Size;
|
|
|
|
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});
|
|
}
|
|
|
|
foreach (@{$opt->{file}}){
|
|
# Handle ~
|
|
$_ =~ s/^~(\w*)/(getpwnam( $1 || $ENV{USER}))[7]/e;
|
|
# Convert to absolute path
|
|
$_ = File::Spec->rel2abs($_);
|
|
}
|
|
|
|
# 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}){
|
|
foreach (@{$opt->{file}}){
|
|
die "File $_ not found\n\n" unless (-e $_);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
# 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?://||;
|
|
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 (<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 {
|
|
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)
|
|
});
|
|
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($file),
|
|
filename => basename($file),
|
|
info => {
|
|
mimetype => mimetype $file,
|
|
size => (stat $file)[7]
|
|
},
|
|
url => $file_uri
|
|
};
|
|
if ($mime =~ m/^image/){
|
|
# The file is an image, change the message type and add the required info so it is
|
|
# displayed as an image
|
|
my ($w,$h) = imgsize($file);
|
|
$json->{msgtype} = 'm.image';
|
|
($json->{info}->{h}, $json->{info}->{w}) = imgsize($file);
|
|
}
|
|
$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($_) 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);
|
|
|