From 914e8af956e47159d664c5577a20cb26da9af55f Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 1 Jan 2023 09:53:10 +1300 Subject: [PATCH] Rewrote the whole entire thing in Lua Shortened list of changes: - Added room aliases - Switched the -C and -c flags - Added the MATRIXSEND_CONFIG environment variable - Changed config format to Lua over custom /bin/sh - Upgraded to Matrix spec 1.5 over whatever version it was previously using - Removed JSON printing (and in turn, pretty printing) - Added actual Matrix error reporting rather than just feeding the JSON to the user - Switched to luaposix getopt() over /bin/sh getopts - Some more changes... --- matrix-send | 551 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 347 insertions(+), 204 deletions(-) diff --git a/matrix-send b/matrix-send index 3d5f606..4d6e5f6 100755 --- a/matrix-send +++ b/matrix-send @@ -1,220 +1,363 @@ -#!/usr/bin/env sh -# matrix-send: send a message to a Matrix room +#!/usr/bin/env lua +--[[ + The GPLv2 License (GPLv2) + Copyright (c) 2022 Jeremy Baxter + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +]]-- -version="1.1" +require "luarocks.loader" +getopt = require "posix.unistd".getopt +mkdir = require "posix.sys.stat".mkdir +bname = require "posix.libgen".basename +http = require "socket.http" +cjson = require "cjson" +ltn12 = require "ltn12" -########################### -#### Generic Functions #### -########################### + version = "2.0" + confdir = string.gsub("~/.config/matrix-send", '~', os.getenv("HOME"), 1) +confpath = confdir .. "/config.lua" +confvarv = os.getenv("MATRIXSEND_CONFIG") +hostname = io.input("/etc/hostname"):read("l") -error () { - printf "\033[31;1merror:\033[0m $1\n" - exit 1 -} +json = {} + uri = {} + sh = os.execute -conf_error () { - printf "\033[31;1mconfiguration error:\033[0m $1\n" - exit 2 -} +----------------- +--- Functions --- +----------------- -warning () { - printf "\033[93;1mwarning:\033[0m $1\n" -} - -vargrep () { - printf "$2\n" | grep "$1" $3 -} - -usage () { - printf "usage: matrix-send [-t type] [-C config] [-chV] message room\n" - exit 1 -} - -help () { - cat < "$cacheloc/matrix-send/access-token"; - fi -} + return false, err + end +end -ClearCache () { - [ -e "$cacheloc/matrix-send/access-token" ] || printf "There is no cache to be cleared.\n" - [ -e "$cacheloc/matrix-send/access-token" ] && rm -rf "$cacheloc/matrix-send/" && printf "Cleared cache\n" - exit 0 -} +-- Makes an HTTP POST request to uri and sends body. Returns the output of the HTTP server. +function post(uri, body) + local respbody = {} -- for the response body + local result, respcode, respheaders, respstatus = http.request { + method = "POST", + url = uri, + source = ltn12.source.string(body), + headers = { + ["content-type"] = "application/json", + ["content-length"] = tostring(#body) + }, + sink = ltn12.sink.table(respbody) + } + return table.concat(respbody) +end -Send () { - if [ -z "$prettyprint" ] - then curl -s -XPOST -d "{"'"'"msgtype"'"'":"'"'"$mtype"'"'", "'"'"body"'"'":"'"'"$message"'"'"}" "https://$server/_matrix/client/r0/rooms/%21$roomid/send/m.room.message?access_token=$token" - else curl -s -XPOST -d "{"'"'"msgtype"'"'":"'"'"$mtype"'"'", "'"'"body"'"'":"'"'"$message"'"'"}" "https://$server/_matrix/client/r0/rooms/%21$roomid/send/m.room.message?access_token=$token" | jq - fi -} +-- Makes an HTTP PUT request to uri and sends body. Returns the output of the HTTP server. +function put(uri, body) + local respbody = {} -- for the response body + local result, respcode, respheaders, respstatus = http.request { + method = "PUT", + url = uri, + source = ltn12.source.string(body), + headers = { + ["content-type"] = "application/json", + ["content-length"] = tostring(#body) + }, + sink = ltn12.sink.table(respbody) + } + return table.concat(respbody) +end -######################## -#### Initial checks #### -######################## +-- Prints a message to stdout in the following format: +-- matrix-send: s +function msg(s) + io.stdout:write(string.format("%s: %s\n", bname(arg[0]), s)) +end -[ -e /usr/local/bin/curl ] || [ -e /usr/bin/curl ] || error "curl not found" +-- Prints an error message to stderr in the following format: +-- matrix-send: s +-- After that, exits and returns 1 to the OS. +function panic(s) + io.stderr:write(string.format("%s: %s\n", bname(arg[0]), s)) + os.exit(1) +end -[ -z "$1" ] && usage -CacheLocation "$HOME/.cache" -while getopts :t:C:chV opt -do - case $opt in - t) - if vargrep "m\.(text|notice)" "$OPTARG" -Eq - then - mtype="$OPTARG" - optind="$OPTIND" - else error "Type not valid (-t)" - fi - ;; - C) - if vargrep "^~" "$OPTARG" -Eq - then config="$OPTARG" - elif vargrep "^/" "$OPTARG" -Eq - then config="$OPTARG" - else error "Configuration file location not valid (-C)" - fi - ;; - c) ClearCache ;; - h) help ;; - V) version ;; - esac -done -unset OPTARG -unset OPTIND +-- Prints an error message to stderr in the following format: +-- matrix-send: confpath: s +-- After that, exits and returns 1 to the OS. +function confpanic(s) + panic(string.format("%s: %s", bname(confpath), s)) + os.exit(1) +end -############################### -#### Configuration loading #### -############################### +--[[ + The configuration file for matrix-send is written in Lua format. + Here is a list of all possible options in the configuration file. + Default values appear after the '=' sign. -# Load configuration -if [ -e "$config" ]; -then . "$config" + login = { + -- The Matrix server to use. + server = nil, + + -- The user to log in to. + username = nil, + + -- The password for the user. + password = nil, + + -- The access token to use (instead of credentials). + -- If token equals nil, credentials are used. + -- If token is not nil, credentials are ignored. + token = nil + + -- The server value needs to be provided. + -- You can choose to login with user credentials or a + -- token. One of them needs to be provided. + } + + cache = { + -- The path to cache access tokens at. + location = "~/.cache/matrix-send", + + -- Disable caching access tokens? + disable = false + } + + rooms = { + -- Room aliases. + -- Here you can add aliases for rooms, + -- instead of having to type the confusing + -- Room ID every single time you send a message. + -- Examples: + --my_alias = "!AbCdEfGhIjKl:burger.land", + --lounge = "!MnOpQrSTuVWxYz:gaming.bruvs" + -- When you want to send to a Matrix room, you + -- can just type the alias instead of the long Room ID. + } + + advanced = { + -- The default event type. + -- Can be either m.text or m.notice. + event = "m.text" + } +--]] + +-- Default configuration file +default_config = [[ +-- Default configuration file for matrix-send +-- In comments, here is a simple example configuration. + +--login = { +-- server = "matrix.envs.net", +-- username = "john", +-- password = "examplepassword +--} + +-- You could also do it this way: + +--login.server = "matrix.envs.net" +--login.username = "john" +--login.password = "examplepassword" + +-- Both configurations do the same thing. + +-- See matrix-send.conf(5) for more information +-- on configuration files. +]] + +------------------------------------ +--- Default configuration values --- +------------------------------------ + +-- Initialise tables +login = {} +cache = {} +rooms = {} +advanced = {} + +-- Cache location +cache.location = "~/.cache/matrix-send" + +-- Disable caching access tokens? +cache.disable = false + +-- Message type +advanced.event = "m.text" + +---------------------- +--- Initial checks --- +---------------------- + +if arg[1] == nil then + io.stderr:write("usage: " .. bname(arg[0]) .. " [-t type] [-c config] [-ChV] message room\n") + os.exit(1) +end + +if confvarv then + if fileexists(confvarv) then + confpath = os.getenv("MATRIXSEND_CONFIG") + else + local _, err = fileexists(os.getenv("MATRIXSEND_CONFIG")) + panic("error opening " .. err) + end +end + +if not fileexists(confpath) then + mkdir(confdir) + f = io.open(confpath, 'w') + f:write(default_config) + f:close() +end +dofile(confpath) + +cache.location = string.gsub(cache.location, '~', os.getenv("HOME"), 1) + +-- Make sure all required values are set +if not login.server then + confpanic("required value 'login.server' left unset") +end + +-- login.token always supersedes login.username and password +if not login.token then + if not login.username then + confpanic("required value 'login.username' left unset") + elseif not login.password then + confpanic("required value 'login.password' left unset") + end +end + +-- Parse options +for opt, optarg, ind in getopt(arg, ':Cc:t:V') do + optind = ind + if opt == 'C' then + msg("clearing cache") + os.execute("rm -rf " .. cache.location) -- TODO: replace this os.execute line + os.exit(0) + elseif opt == 'c' then + confpath = optarg + elseif opt == 't' then + if optarg:match("m%.text") + or optarg:match("m%.notice") then + advanced.event = optarg + else panic("unknown message type '" .. optarg .. "'") + end + elseif opt == 'V' then + io.stdout:write("matrix-send version " .. version .. '\n') + io.stdout:write(_VERSION .. '\n') + os.exit(0) + elseif opt == '?' then + panic("unknown option " .. arg[optind-1]) + os.exit(1) + elseif opt == ':' then + panic("missing argument for option " .. arg[optind-1]) + os.exit(1) + end +end + +if optind == nil then + message = arg[1] + room = arg[2] else - warning "~/.config/matrix-send.conf doesn't exist; creating it" - mkdir -p $HOME/.config - touch $HOME/.config/matrix-send.conf -fi + message = arg[optind+1] + room = arg[optind+2] +end -# Run checks for essential directives -[ -z $server ] && conf_error "Server directive is not present" -[ -z $username ] && conf_error "Username directive is not present" -[ -z "$password" ] && conf_error "Password directive is not present" - -############## -#### Main #### -############## - -# Get token and cache it (unless NoCache is set) -[ -e "$cacheloc/matrix-send/access-token" ] && token=$(cat $cacheloc/matrix-send/access-token) -[ -e "$cacheloc/matrix-send/access-token" ] || GetAccessToken -CacheAccessToken - -if [ -z "$2" ]; -then error "Room ID not specified."; +if room:match("^!") then + room_id = room else - shift $((OPTIND-1)) - message="$1" - roomid_input="$2" - if [ -z "$mtype" ]; then mtype="$defaultevent"; fi; - if vargrep '!' "$roomid_input" -qo - then roomid="$(printf "$roomid_input" | sed 's/!//g')" - else roomid="$roomid_input"; fi - Send -fi + if rooms[room] ~= nil then + room_id = rooms[room] + else + panic("alias '" .. room .. "' invalid") + end +end + +if fileexists(cache.location .. "/txnid") then + txnidf = io.open(cache.location .. "/txnid", 'r') + txnid = txnidf:read('l') + txnidf:close() +else + txnid = 0 +end + +uri.login = string.format("https://%s/_matrix/client/v3/login", login.server) + +-- Assemble various JSON messages +json.message = string.format([[ +{ + "body": "%s", + "msgtype": "%s" +} +]], message, advanced.event) + +-- Get the access token if credentials are used +if not login.token then + if fileexists(cache.location .. "/token") then + cf = io.open(cache.location .. "/token", 'r') + login.token = cf:read('l') + cf:close() + else + -- json.login should only be assigned if it needs to be assigned + json.login = string.format([[ + { + "identifier": { + "type": "m.id.user", + "user": "%s" + }, + "initial_device_display_name": "matrix-send@%s", + "password": "%s", + "type": "m.login.password" + } + + ]], login.username, hostname, login.password) + + -- Send the request! + local body = post(uri.login, json.login) + + -- What else do I call it? + local t = cjson.decode(body) + + -- If there was a server side error, print the error and exit. + if t.error then + panic(login.server .. ": " .. t.error) + end + + -- Log the access token + login.token = t.access_token + + -- Cache the access token + mkdir(cache.location) + cf = io.open(cache.location .. "/token", 'w+') + cf:write(login.token) + cf:close() + end +end + +uri.message = string.format("https://%s/_matrix/client/v3/rooms/%s/send/m.room.message/%d?access_token=%s", + login.server, room_id, txnid, login.token) + +-- Send the message! +body = put(uri.message, json.message) +t = cjson.decode(body) +if t.error then + panic(login.server .. ": " .. t.error) +end + +-- Increment txnid and cache +txnidf = io.open(cache.location .. "/txnid", 'w+') +txnidf:write(txnid + 1) +txnidf:close()