c4mate/c4mate
2024-08-26 10:29:15 +02:00

452 lines
13 KiB
Lua
Executable file

#!/bin/lua
--
-- c4mate: command line client for the C4 mukas instance (aka Mate-Pad).
--
-- Author: Shy
-- License: CC0
-- Enter your user name here:
USER = nil
HOST = "http://mate.labor.koeln.ccc.de"
function utf8_decode(str)
-- Replace JSON Unicode escape sequences with chars.
if str == nil then return "" end
return string.gsub(str, "\\u(%x%x%x%x)",
function (s) return utf8.char(tonumber(s, 16)) end)
end
function url_encode(str)
-- URL-encode string. Used only for usernames.
-- Todo: make this more sophisticated.
return string.gsub(str, " ", "%%20")
end
MatePad = {
CURL_FLAGS = "--silent --show-error --fail --user-agent 'c4mate' --max-redirs 0 --max-filesize 65536",
}
function MatePad:new(HOST, USER)
local o = {
HOST = HOST,
USER = USER,
COOKIE_JAR,
balance
}
-- Metatable. Make garbage collector delete our temporary cookie jar.
local m = {
__index = self,
__gc = function (f)
if f.COOKIE_JAR ~= nil then os.remove(f.COOKIE_JAR) end
end
}
return setmetatable(o, m)
end
-- Fetch response from given api endpoint.
function MatePad:fetch(api, data)
local responce, curl, ok, exit, signal
-- Set up cookie jar if not yet present.
if self.COOKIE_JAR == nil then self.COOKIE_JAR = os.tmpname() end
if data ~= nil then
local flags = ""
for k, v in pairs(data) do
flags = string.format("%s --data-urlencode \"%s=%s\"", flags, k, v)
end
curl = io.popen(string.format(
"curl %s --cookie \"%s\" --cookie-jar \"%s\" %s \"%s/%s\"",
self.CURL_FLAGS, self.COOKIE_JAR, self.COOKIE_JAR, flags, self.HOST, api))
else
curl = io.popen(string.format(
"curl %s --cookie \"%s\" --cookie-jar \"%s\" \"%s/%s\"",
self.CURL_FLAGS, self.COOKIE_JAR, self.COOKIE_JAR, self.HOST, api))
end
responce = curl:read("a")
ok, exit, signal = curl:close()
if not ok then
if exit == "signal" then
io.stderr:write("Error: 'curl' received signal "..signal..".\n")
else
io.stderr:write("Error: 'curl' returned exit code "..signal..". ")
if signal == 22 then
io.stderr:write("Maybe incorrect username?\n")
else
io.stderr:write("\n")
end
end
end
return responce
end
-- Initialize session.
function MatePad:init()
self.token = self:fetch("api/csrf_token")
-- Todo: check token.
end
-- Update user balance.
function MatePad:update_balance()
self.balance = self:fetch("api/user/"..url_encode(self.USER).."/balance?_csrf_token="..self.token)
return self.balance ~= ""
end
-- Parse history.
function MatePad:parse_log()
local pipe, ok, exit, signal
local datefile = os.tmpname()
local log = {}
-- sed #1: Insert newlines between log entries.
-- sed #2: Extract time and write it into datefile. Then extract and print
-- oldbalance, newbalance, parameter and method. Depending on method, print
-- either reason or item_name. If any extraction fails we fail with exit
-- code 1.
pipe = io.popen(string.format([=[
curl %s --header "Accept: application/json" "%s/api/user/%s/log?type=json" | \
sed -n --sandbox '
/.*\[\([^]]*\)\].*/{
s//\1/
s/} *,/&\n/gp
}' | \
sed -n '
h
s/.*"time" *: *"\([^"]\+\)".*/\1UTC/
T fail
w %s
g
s/.*"oldbalance" *: *\(-\?[0-9]\+\).*/\1/p
T fail
g
s/.*"newbalance" *: *\(-\?[0-9]\+\).*/\1/p
T fail
g
s/.*"parameter" *: *\([0-9]\+\).*/\1/p
T fail
g
s/.*"method" *: *"\([^"]\+\)".*/\1/p
T fail
/set_balance\|transfer/{
g
s/.*"reason" *: *\(null\|"\([^"]*\)"\).*/\2/p
T fail
b
}
g
s/.*"item_name" *: *\(null\|"\([^"]*\)"\).*/\2/p
T fail
b
:fail
q 1 ']=], self.CURL_FLAGS, self.HOST, url_encode(self.USER), datefile))
while true do
local entry = {}
local oldbalance = tonumber(pipe:read("l"))
if oldbalance == nil then break end
entry.oldbalance = oldbalance
entry.newbalance = tonumber(pipe:read("l"))
entry.parameter = tonumber(pipe:read("l"))
entry.method = pipe:read("l")
if entry.method == "buy" or entry.method == "recharge" then
entry.name = utf8_decode(pipe:read("l"))
else
-- set_balance, transferTo, transferFrom
entry.reason = utf8_decode(pipe:read("l"))
end
table.insert(log, entry)
end
-- Check pipe exit status.
ok, exit, signal = pipe:close()
if not ok then -- sed error
io.stderr:write("Sorry, there was an error while parsing the API response.\n")
-- An empty log will throw no further exceptions.
log = {}
elseif log == {} then -- curl error
io.stderr:write("Sorry, there was an error while connecting the API.\n")
end
-- Convert timestamps to local time.
local timestamps = io.popen("date -f "..datefile.." +'%_c'")
for i = 1, #log do
log[i].time = timestamps:read("l")
end
-- Check date exit code.
ok, exit, signal = timestamps:close()
os.remove(datefile)
if not ok then
io.stderr:write(string.format(
"Warning: 'date' exited with non-zero exit code (%s/%s).\n",
exit, signal))
end
return log
end
-- Print given history.
function MatePad:print_log(log)
local event
for i = #log, 1, -1 do
event = log[i]
if event.method == "buy" or event.method == "recharge" then
print(string.format(
"[%s] Bought %s (#%d)",
event.time, event.name, event.parameter
))
elseif event.method == "set_balance" then
print(string.format(
"[%s] Set balance from %.2f € to %.2f €",
event.time, event.oldbalance/100, event.newbalance/100
))
elseif event.method == "transferTo" then
print(string.format(
"[%s] Transferred %.2f € to #%d (%s)",
event.time, (event.oldbalance - event.newbalance)/100,
event.parameter, event.reason
))
elseif event.method == "transferFrom" then
print(string.format(
"[%s] Received %.2f € from #%d (%s)",
event.time, (event.newbalance - event.oldbalance)/100,
event.parameter, event.reason
))
else
print("Warning: unknown event \""..event.method.."\".")
end
end
end
-- Get available items.
function MatePad:get_roster()
local pipe, ok, exit, signal
local roster = {}
-- sed #1: Extract items section and insert newlines between entries.
-- sed #2: Extract and print id, name and price. If any extraction fails we
-- fail with exit code 1.
pipe = io.popen(string.format([=[
curl %s --header "Accept: application/json" "%s/api/items" | \
sed -n --sandbox '
/.*"items": *\[\( *{.*}\)*\].*/{
s//\1/
s/} *,/&\n/gp
}' | \
sed -n --sandbox '
h
s/.*"id" *: *\([0-9]\+\).*/\1/p
T fail
g
s/.*"name" *: *"\([^"]*\)".*/\1/p
T fail
g
s/.*"price" *: *\(-\?[0-9]\+\).*/\1/p
T fail
b
:fail
q 1 ']=], self.CURL_FLAGS, self.HOST))
while true do
local item = {}
item.id = tonumber(pipe:read("l"))
if item.id == nil then break end
item.name = utf8_decode(pipe:read("l"))
item.price = tonumber(pipe:read("l"))
table.insert(roster, item)
end
ok, exit, signal = pipe:close()
if not ok then -- sed error
io.stderr:write("Sorry, there was an error while parsing the API response.\n")
-- Empty table will throw no exceptions.
return {}
elseif roster == nil then -- curl error
io.stderr:write("Sorry, there was an error while connecting the API.\n")
return {}
end
return roster
end
-- Buy given item.
function MatePad:buy(id)
return self:fetch(string.format(
"api/user/%s/buy/%d?_csrf_token=%s",
url_encode(self.USER), id, self.token))
end
-- Set new balance (in cents).
function MatePad:set_balance(amount)
return self:fetch(string.format(
"api/user/%s/balance?_csrf_token=%s",
url_encode(self.USER), self.token),
{newbalance = amount})
end
-- Transfer credits to given user.
function MatePad:give(amount, recipient, reason)
return self:fetch(string.format(
"api/user/%s/transfer?_csrf_token=%s",
url_encode(self.USER), self.token),
{
recipient = recipient,
amount = amount,
reason = reason or ""
})
end
-- Process help and username options.
if arg[1] == "-h" or
arg[1] == "--help" then
-- Usage.
print([=[
Usage: c4mate [-u <user>] [ <query> | -b <id> | -l | +/-<amount> | -g <amount> <user> [<reason>]]
Display current balance:
c4mate
Find and buy item (interactive):
c4mate <query>
Buy by item id (non-interactive):
c4mate -b|--buy <id>
Show log:
c4mate -l|--log
Add or subtract credits:
c4mate <difference>
Give credits:
c4mate -g <amount> <user> [<reason>]]=])
os.exit(0)
end
-- Parse -u flag (which may be given multiple times).
while arg[1] == "-u" do
table.remove(arg, 1)
USER = arg[1]
table.remove(arg, 1)
end
-- Set up username.
if USER == nil then
USER = os.getenv("USER")
if USER == nil then
io.stderr:write("Error: no username given and $USER environment variable empty!\n")
os.exit(1)
end
end
pad = MatePad:new(HOST, USER)
if arg[1] == nil then
-- Print balance.
pad:init()
if pad:update_balance() then
print(string.format(
"Balance of user %s: %.2f €", pad.USER, pad.balance/100))
end
elseif arg[1] == "-l" or
arg[1] == "--log" then
-- Print log.
pad:print_log(pad:parse_log())
elseif arg[1] == "-g" or
arg[1] == "--give" then
-- Give.
local amount = tonumber(arg[2])
local recipient = arg[3]
local reason = table.concat(arg, " ", 4)
if amount == nil or recipient == nil then
print("Error: could not parse amount and/or recipient.")
print("Usage: c4mate --give <amount> <user> [<reason>]")
return
end
pad:init()
local ok = pad:give(amount, recipient, reason)
if ok == "OK" then
pad:update_balance()
print(string.format(
"%.2f € transferred from %s to %s.\nNew balance of user %s: %.2f €",
amount, pad.USER, recipient, pad.USER, pad.balance/100))
else
print("Sorry, there was an error. ("..ok..")")
end
elseif arg[1] == "-b" or arg[1] == "--buy" then
local id = tonumber(arg[2])
if id == nil then
print("Usage: c4mate --buy <id>")
return
end
pad:init()
if pad:buy(id) == "OK" then
pad:update_balance()
print(string.format(
"Bought #%s. New balance: %.2f €.",
id, pad.balance/100))
else
print("Error while attempting to buy product #"..id..".")
end
elseif tonumber(arg[1]) ~= nil then
-- Add to (or subtract from) balance.
pad:init()
if pad:update_balance() then
new = math.tointeger(pad.balance + tonumber(arg[1])*100)
if pad:set_balance(new) == "OK" then
pad:update_balance()
print(string.format(
"New balance of user %s: %.2f €", pad.USER, pad.balance/100))
else
print("Sorry, something went wrong. (Unexpected reply from API.)")
end
end
else
-- Find and buy item.
local roster, query, input, ok
roster = pad:get_roster()
query = string.lower(table.concat(arg, " "))
local menu = {}
for i = 1, #roster do
local item = roster[i]
if string.match(string.lower(item.name), query) then
table.insert(menu, item)
end
end
if #menu == 0 then
print("Sorry, no matching item found.")
else
print("Press [y] to accept, [n] for next or anything else to cancel:")
end
-- Sort menu by id.
table.sort(menu, function (e1, e2) return e1.id < e2.id end)
-- Check if we can invoke stty.
is_tty = os.execute("tty -s") == true
for i, item in ipairs(menu) do
io.stdout:write(string.format("[%d/%d] Buy %s (#%d) for %.2f €? ",
i, #menu, item.name, item.id, item.price/100))
-- Turn off canonical mode, so we can read a char without the user
-- having to press enter.
if is_tty then os.execute("stty -icanon") end
ok, input = pcall(io.read, 1)
if is_tty then os.execute("stty icanon") end
if not ok then break end -- ^C
if input == "y" then
pad:init()
if pad:buy(item.id) == "OK" then
pad:update_balance()
print(string.format(
"\nBought %s. New balance: %.2f €.",
item.name, pad.balance/100))
end
break
elseif input == "n" then print() -- force newline
elseif input ~= "\n" then
print("\nCancelled.")
break
end
end
end