Initial commit
This commit is contained in:
commit
14602222e7
3 changed files with 603 additions and 0 deletions
453
c4mate
Executable file
453
c4mate
Executable file
|
@ -0,0 +1,453 @@
|
|||
#!/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",
|
||||
event.time, event.name
|
||||
))
|
||||
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 availably 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>]]=])
|
||||
return
|
||||
|
||||
-- Parse -u flag.
|
||||
elseif arg[1] == "-u" then
|
||||
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 -g <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
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue