474 lines
14 KiB
Lua
Executable file
474 lines
14 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"
|
|
|
|
local function utf8_decode(str)
|
|
-- Replace JSON Unicode escape sequences with chars.
|
|
if str == nil then return "" end
|
|
str = string.gsub(str, "\\\"", "\"")
|
|
if utf8 == nil then return str end -- Only available in Lua >= 5.3.
|
|
return string.gsub(str, "\\u(%x%x%x%x)",
|
|
function (s) return utf8.char(tonumber(s, 16)) end)
|
|
end
|
|
|
|
local 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(size)
|
|
local pipe, ok, exit, signal
|
|
local datefile = os.tmpname()
|
|
local log = {}
|
|
-- sed #1: Insert newlines between log entries.
|
|
-- sed #2: Quit after $size lines. 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.
|
|
-- Note: we allow escaped quotation marks in "reason" and "item_name".
|
|
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 '
|
|
%d q
|
|
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), size+1, datefile))
|
|
|
|
while true do
|
|
local entry = {}
|
|
local oldbalance = tonumber(pipe:read())
|
|
if oldbalance == nil then break end
|
|
entry.oldbalance = oldbalance
|
|
entry.newbalance = tonumber(pipe:read())
|
|
entry.parameter = tonumber(pipe:read())
|
|
entry.method = pipe:read()
|
|
if entry.method == "buy" or entry.method == "recharge" then
|
|
entry.name = utf8_decode(pipe:read())
|
|
else
|
|
-- set_balance, transferTo, transferFrom
|
|
entry.reason = utf8_decode(pipe:read())
|
|
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()
|
|
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 user #%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 user #%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())
|
|
if item.id == nil then break end
|
|
item.name = utf8_decode(pipe:read())
|
|
item.price = tonumber(pipe:read())
|
|
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
|
|
|
|
-- Return id of last bought item in log.
|
|
function MatePad:last_bought()
|
|
local log = self:parse_log(50)
|
|
for i = 1, #log do
|
|
if log[i].method == "buy" then
|
|
return log[i].parameter
|
|
end
|
|
end
|
|
return nil
|
|
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
|
|
|
|
--
|
|
-- 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
|
|
|
|
-- 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, special id '0' will re-buy last item in log):
|
|
c4mate -b|--buy <id>
|
|
Show log:
|
|
c4mate -l|--log [<size>]
|
|
Add or subtract credits:
|
|
c4mate +/-<amount>
|
|
Give credits:
|
|
c4mate -g|--give <amount> <user> [<reason>]]=])
|
|
os.exit(0)
|
|
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
|
|
local 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.
|
|
local size = tonumber(arg[2]) or 10
|
|
size = math.max(0, size)
|
|
pad:print_log(pad:parse_log(size))
|
|
|
|
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
|
|
|
|
if id == 0 then id = pad:last_bought() 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
|
|
local new = math.floor(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. Lua5.1 returns 0 on success, later versions
|
|
-- return true.
|
|
local is_tty = os.execute("tty -s")
|
|
is_tty = is_tty == true or is_tty == 0
|
|
|
|
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
|
|
|