#!/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 . ]]-- 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" 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") matrix = {} json = {} uri = {} sh = os.execute ----------------- --- Functions --- ----------------- -- Returns true if the given file name file exists, otherwise returns false. function fileexists(file) local f, err = io.open(file, 'r') if f then f:close() return true else return false, err end end -- 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 -- 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 -- 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 ------------------------ --- Matrix functions --- ------------------------ function matrix.login(uri, json) local respbody = {} local result, respcode, respheaders, respstatus = http.request { method = "POST", url = uri, source = ltn12.source.string(json), headers = { ["content-type"] = "application/json", ["content-length"] = tostring(#json) }, sink = ltn12.sink.table(respbody) } local body = table.concat(respbody) local t = cjson.decode(body) if t.error then panic("server: " .. t.error) else return t.access_token end end function matrix.send(uri, json, login_uri, login_json) local respbody = {} local result, respcode, respheaders, respstatus = http.request { method = "PUT", url = uri, source = ltn12.source.string(json), headers = { ["content-type"] = "application/json", ["content-length"] = tostring(#json) }, sink = ltn12.sink.table(respbody) } local body = table.concat(respbody) local t = cjson.decode(body) if t.error then if t.errcode == "M_UNKNOWN_TOKEN" then msg("token expired/invalidated; re-authenticating") -- I mean, it works t = setmetatable({}, { __concat = function(left, right) if type(left) == "string" then return left .. tostring(right) end return tostring(left) .. tostring(right) end }) t.token = matrix.login(login_uri, login_json) if not cache.disable then mkdir(cache.location) cf = io.open(cache.location .. "/token", 'w+') cf:write(t.token) cf:close() end matrix.send(string.gsub(uri, "access_token=.+", "access_token=" .. t.token), json, login_uri, login_json) else panic("server: " .. t.error) end else return t end end -- Default configuration file default_config = [[ -- Default configuration file for matrix-send -- In comments, here is a simple example configuration. --login = { -- server = "envs.net", -- username = "john", -- password = "examplepassword --} -- You could also do it this way: --login.server = "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]) .. " [-c config] [-t type] [-CV] 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 message = arg[optind+1] room = arg[optind+2] end if room:match("^!") then room_id = room else 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 -- Get .well-known uri.well_known = string.format("https://%s/.well-known/matrix/client", login.server) wellknown_body, errno = http.request(uri.well_known) if errno ~= 200 then panic("server: Error " .. errno .. " while getting /.well-known/matrix/client") end wellknown_t = cjson.decode(wellknown_body) login.server = wellknown_t["m.homeserver"].base_url -- Assign URIs and JSON bodies uri.login = string.format("%s/_matrix/client/v3/login", login.server) 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) 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 -- Login login.token = matrix.login(uri.login, json.login) -- Cache the access token if not cache.disable then mkdir(cache.location) cf = io.open(cache.location .. "/token", 'w+') cf:write(login.token) cf:close() end end end uri.message = string.format("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%d?access_token=%s", login.server, room_id, txnid, login.token) -- Send the message! matrix.send(uri.message, json.message, uri.login, json.login) -- Increment txnid and cache if not cache.disable then txnidf = io.open(cache.location .. "/txnid", 'w+') txnidf:write(txnid + 1) txnidf:close() end