--[[ Copyright (c) 2009-2011 Daniele Alessandri Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] module('Redis', package.seeall) local commands, network, request, response = {}, {}, {}, {} local defaults = { host = '127.0.0.1', port = 6379, tcp_nodelay = true, path = nil } local protocol = { newline = '\r\n', ok = 'OK', err = 'ERR', queued = 'QUEUED', null = 'nil' } local lua_error = error local function default_error_fn(message, level) lua_error(message, (level or 1) + 1) end local function merge_defaults(parameters) if parameters == nil then parameters = {} end for k, v in pairs(defaults) do if parameters[k] == nil then parameters[k] = defaults[k] end end return parameters end local function parse_boolean(v) if v == '1' or v == 'true' or v == 'TRUE' then return true elseif v == '0' or v == 'false' or v == 'FALSE' then return false else return nil end end local function toboolean(value) return value == 1 end local function fire_and_forget(client, command) -- let's fire and forget! the connection is closed as soon -- as the SHUTDOWN command is received by the server. client.network.write(client, command .. protocol.newline) return false end local function zset_range_request(client, command, ...) local args, opts = {...}, { } if #args >= 1 and type(args[#args]) == 'table' then local options = table.remove(args, #args) if options.withscores then table.insert(opts, 'WITHSCORES') end end for _, v in pairs(opts) do table.insert(args, v) end request.multibulk(client, command, args) end local function zset_range_byscore_request(client, command, ...) local args, opts = {...}, { } if #args >= 1 and type(args[#args]) == 'table' then local options = table.remove(args, #args) if options.limit then table.insert(opts, 'LIMIT') table.insert(opts, options.limit.offset or options.limit[1]) table.insert(opts, options.limit.count or options.limit[2]) end if options.withscores then table.insert(opts, 'WITHSCORES') end end for _, v in pairs(opts) do table.insert(args, v) end request.multibulk(client, command, args) end local function zset_range_reply(reply, command, ...) local args = {...} local opts = args[4] if opts and (opts.withscores or string.lower(tostring(opts)) == 'withscores') then local new_reply = { } for i = 1, #reply, 2 do table.insert(new_reply, { reply[i], reply[i + 1] }) end return new_reply else return reply end end local function zset_store_request(client, command, ...) local args, opts = {...}, { } if #args >= 1 and type(args[#args]) == 'table' then local options = table.remove(args, #args) if options.weights and type(options.weights) == 'table' then table.insert(opts, 'WEIGHTS') for _, weight in ipairs(options.weights) do table.insert(opts, weight) end end if options.aggregate then table.insert(opts, 'AGGREGATE') table.insert(opts, options.aggregate) end end for _, v in pairs(opts) do table.insert(args, v) end request.multibulk(client, command, args) end local function mset_filter_args(client, command, ...) local args, arguments = {...}, {} if (#args == 1 and type(args[1]) == 'table') then for k,v in pairs(args[1]) do table.insert(arguments, k) table.insert(arguments, v) end else arguments = args end request.multibulk(client, command, arguments) end local function hash_multi_request_builder(builder_callback) return function(client, command, ...) local args, arguments = {...}, { } if #args == 2 then table.insert(arguments, args[1]) for k, v in pairs(args[2]) do builder_callback(arguments, k, v) end else arguments = args end request.multibulk(client, command, arguments) end end local function parse_info(response) local info = {} response:gsub('([^\r\n]*)\r\n', function(kv) local k,v = kv:match(('([^:]*):([^:]*)'):rep(1)) if (k:match('db%d+')) then info[k] = {} v:gsub(',', function(dbkv) local dbk,dbv = kv:match('([^:]*)=([^:]*)') info[k][dbk] = dbv end) else info[k] = v end end) return info end local function parse_info_new(response) local info, current = {}, nil response:gsub('([^\r\n]*)\r\n', function(kv) if kv == '' then return end local section = kv:match(('^# (%w+)'):rep(1)) if section then current = section:lower() info[current] = {} return end local k,v = kv:match(('([^:]*):([^:]*)'):rep(1)) if (k:match('db%d+')) then info[current][k] = {} v:gsub(',', function(dbkv) local dbk,dbv = kv:match('([^:]*)=([^:]*)') info[current][dbk] = dbv end) else info[current][k] = v end end) return info end local function load_methods(proto, methods) local redis = setmetatable ({}, getmetatable(proto)) for i, v in pairs(proto) do redis[i] = v end for i, v in pairs(methods) do redis[i] = v end return redis end local function create_client(proto, client_socket, methods) local redis = load_methods(proto, methods) redis.network = { socket = client_socket, read = network.read, write = network.write, } redis.requests = { multibulk = request.multibulk, } return redis end -- ############################################################################ function network.write(client, buffer) local _, err = client.network.socket:send(buffer) if err then client.error(err) end end function network.read(client, len) if len == nil then len = '*l' end local line, err = client.network.socket:receive(len) if not err then return line else client.error('connection error: ' .. err) end end -- ############################################################################ function response.read(client) local res = client.network.read(client) local prefix = res:sub(1, -#res) local handler = protocol.prefixes[prefix] if not handler then client.error('unknown response prefix: '..prefix) end return handler(client, res) end function response.status(client, data) local sub = data:sub(2) if sub == protocol.ok then return true elseif sub == protocol.queued then return { queued = true } else return sub end end function response.error(client, data) local err_line = data:sub(2) if err_line:sub(1, 3) == protocol.err then client.error('redis error: ' .. err_line:sub(5)) else client.error('redis error: ' .. err_line) end end function response.bulk(client, data) local str = data:sub(2) local len = tonumber(str) if not len then client.error('cannot parse ' .. str .. ' as data length') end if len == -1 then return nil end local next_chunk = client.network.read(client, len + 2) return next_chunk:sub(1, -3); end function response.multibulk(client, data) local str = data:sub(2) local list_count = tonumber(str) if list_count == -1 then return nil else local list = {} if list_count > 0 then for i = 1, list_count do table.insert(list, i, response.read(client)) end end return list end end function response.integer(client, data) local res = data:sub(2) local number = tonumber(res) if not number then if res == protocol.null then return nil end client.error('cannot parse '..res..' as a numeric response.') end return number end protocol.prefixes = { ['+'] = response.status, ['-'] = response.error, ['$'] = response.bulk, ['*'] = response.multibulk, [':'] = response.integer, } -- ############################################################################ function request.raw(client, buffer) local bufferType = type(buffer) if bufferType == 'table' then client.network.write(client, table.concat(buffer)) elseif bufferType == 'string' then client.network.write(client, buffer) else client.error('argument error: ' .. bufferType) end end function request.multibulk(client, command, ...) local args = {...} local args_len = #args local buffer = { true, true } local proto_nl = protocol.newline if args_len == 1 and type(args[1]) == 'table' then args_len, args = #args[1], args[1] end buffer[1] = '*' .. tostring(args_len + 1) .. proto_nl buffer[2] = '$' .. #command .. proto_nl .. command .. proto_nl for _, argument in pairs(args) do s_argument = tostring(argument) table.insert(buffer, '$' .. #s_argument .. proto_nl .. s_argument .. proto_nl) end request.raw(client, buffer) end -- ############################################################################ local function custom(command, send, parse) return function(client, ...) local has_reply = send(client, command, ...) if has_reply == false then return end local reply = response.read(client) if type(reply) == 'table' and reply.queued then reply.parser = parse return reply else if parse then return parse(reply, command, ...) else return reply end end end end function command(command, opts) if opts == nil or type(opts) == 'function' then return custom(command, request.multibulk, opts) else return custom(command, opts.request or request.multibulk, opts.response) end end local define_command_impl = function(target, name, opts) local opts = opts or {} target[string.lower(name)] = custom( opts.command or string.upper(name), opts.request or request.multibulk, opts.response or nil ) end function define_command(name, opts) define_command_impl(commands, name, opts) end local undefine_command_impl = function(target, name) target[string.lower(name)] = nil end function undefine_command(name) undefine_command_impl(commands, name) end -- ############################################################################ local client_prototype = {} client_prototype.raw_cmd = function(client, buffer) request.raw(client, buffer .. protocol.newline) return response.read(client) end client_prototype.define_command = function(client, name, opts) define_command_impl(client, name, opts) end client_prototype.undefine_command = function(client, name) undefine_command_impl(client, name) end -- Command pipelining client_prototype.pipeline = function(client, block) local requests, replies, parsers = {}, {}, {} local socket_write, socket_read = client.network.write, client.network.read client.network.write = function(_, buffer) table.insert(requests, buffer) end -- TODO: this hack is necessary to temporarily reuse the current -- request -> response handling implementation of redis-lua -- without further changes in the code, but it will surely -- disappear when the new command-definition infrastructure -- will finally be in place. client.network.read = function() return '+QUEUED' end local pipeline = setmetatable({}, { __index = function(env, name) local cmd = client[name] if not cmd then client.error('unknown redis command: ' .. name, 2) end return function(self, ...) local reply = cmd(client, ...) table.insert(parsers, #requests, reply.parser) return reply end end }) local success, retval = pcall(block, pipeline) client.network.write, client.network.read = socket_write, socket_read if not success then client.error(retval, 0) end client.network.write(client, table.concat(requests, '')) for i = 1, #requests do local reply, parser = response.read(client), parsers[i] if parser then reply = parser(reply) end table.insert(replies, i, reply) end return replies, #requests end -- Publish/Subscribe do local channels = function(channels) if type(channels) == 'string' then channels = { channels } end return channels end local subscribe = function(client, ...) request.multibulk(client, 'subscribe', ...) end local psubscribe = function(client, ...) request.multibulk(client, 'psubscribe', ...) end local unsubscribe = function(client, ...) request.multibulk(client, 'unsubscribe') end local punsubscribe = function(client, ...) request.multibulk(client, 'punsubscribe') end local consumer_loop = function(client) local aborting, subscriptions = false, 0 local abort = function() if not aborting then unsubscribe(client) punsubscribe(client) aborting = true end end return coroutine.wrap(function() while true do local message local response = response.read(client) if response[1] == 'pmessage' then message = { kind = response[1], pattern = response[2], channel = response[3], payload = response[4], } else message = { kind = response[1], channel = response[2], payload = response[3], } end if string.match(message.kind, '^p?subscribe$') then subscriptions = subscriptions + 1 end if string.match(message.kind, '^p?unsubscribe$') then subscriptions = subscriptions - 1 end if aborting and subscriptions == 0 then break end coroutine.yield(message, abort) end end) end client_prototype.pubsub = function(client, subscriptions) if type(subscriptions) == 'table' then if subscriptions.subscribe then subscribe(client, channels(subscriptions.subscribe)) end if subscriptions.psubscribe then psubscribe(client, channels(subscriptions.psubscribe)) end end return consumer_loop(client) end end -- Redis transactions (MULTI/EXEC) do local function identity(...) return ... end local emptytable = {} local function initialize_transaction(client, options, block, queued_parsers) local coro = coroutine.create(block) if options.watch then local watch_keys = {} for _, key in pairs(options.watch) do table.insert(watch_keys, key) end if #watch_keys > 0 then client:watch(unpack(watch_keys)) end end local transaction_client = setmetatable({}, {__index=client}) transaction_client.exec = function(...) client.error('cannot use EXEC inside a transaction block') end transaction_client.multi = function(...) coroutine.yield() end transaction_client.commands_queued = function() return #queued_parsers end assert(coroutine.resume(coro, transaction_client)) transaction_client.multi = nil transaction_client.discard = function(...) local reply = client:discard() for i, v in pairs(queued_parsers) do queued_parsers[i]=nil end coro = initialize_transaction(client, options, block, queued_parsers) return reply end transaction_client.watch = function(...) client.error('WATCH inside MULTI is not allowed') end setmetatable(transaction_client, { __index = function(t, k) local cmd = client[k] if type(cmd) == "function" then local function queuey(self, ...) local reply = cmd(client, ...) assert((reply or emptytable).queued == true, 'a QUEUED reply was expected') table.insert(queued_parsers, reply.parser or identity) return reply end t[k]=queuey return queuey else return cmd end end }) client:multi() return coro end local function transaction(client, options, coroutine_block, attempts) local queued_parsers, replies = {}, {} local retry = tonumber(attempts) or tonumber(options.retry) or 2 local coro = initialize_transaction(client, options, coroutine_block, queued_parsers) local success, retval if coroutine.status(coro) == 'suspended' then success, retval = coroutine.resume(coro) else -- do not fail if the coroutine has not been resumed (missing t:multi() with CAS) success, retval = true, 'empty transaction' end if #queued_parsers == 0 or not success then client:discard() assert(success, retval) return replies, 0 end local raw_replies = client:exec() if not raw_replies then if (retry or 0) <= 0 then client.error("MULTI/EXEC transaction aborted by the server") else --we're not quite done yet return transaction(client, options, coroutine_block, retry - 1) end end for i, parser in pairs(queued_parsers) do table.insert(replies, i, parser(raw_replies[i])) end return replies, #queued_parsers end client_prototype.transaction = function(client, arg1, arg2) local options, block if not arg2 then options, block = {}, arg1 elseif arg1 then --and arg2, implicitly options, block = type(arg1)=="table" and arg1 or { arg1 }, arg2 else client.error("Invalid parameters for redis transaction.") end if not options.watch then watch_keys = { } for i, v in pairs(options) do if tonumber(i) then table.insert(watch_keys, v) options[i] = nil end end options.watch = watch_keys elseif not (type(options.watch) == 'table') then options.watch = { options.watch } end if not options.cas then local tx_block = block block = function(client, ...) client:multi() return tx_block(client, ...) --can't wrap this in pcall because we're in a coroutine. end end return transaction(client, options, block) end end -- ############################################################################ local function connect_tcp(socket, parameters) local host, port = parameters.host, tonumber(parameters.port) local ok, err = socket:connect(host, port) if not ok then default_error_fn('could not connect to '..host..':'..port..' ['..err..']') end socket:setoption('tcp-nodelay', parameters.tcp_nodelay) return socket end local function connect_unix(socket, parameters) local ok, err = socket:connect(parameters.path) if not ok then default_error_fn('could not connect to '..parameters.path..' ['..err..']') end return socket end local function create_connection(parameters) local perform_connection, socket if parameters.scheme == 'unix' then perform_connection, socket = connect_unix, require('socket.unix') assert(socket, 'your build of LuaSocket does not support UNIX domain sockets') else if parameters.scheme then local scheme = parameters.scheme assert(scheme == 'redis' or scheme == 'tcp', 'invalid scheme: '..scheme) end perform_connection, socket = connect_tcp, require('socket').tcp end return perform_connection(socket(), parameters) end function connect(...) local args, parameters = {...}, nil if #args == 1 then if type(args[1]) == 'table' then parameters = args[1] else local uri = require('socket.url') parameters = uri.parse(select(1, ...)) if parameters.scheme then if parameters.query then for k, v in parameters.query:gmatch('([-_%w]+)=([-_%w]+)') do if k == 'tcp_nodelay' or k == 'tcp-nodelay' then parameters.tcp_nodelay = parse_boolean(v) end end end else parameters.host = parameters.path end end elseif #args > 1 then local host, port = unpack(args) parameters = { host = host, port = port } end local socket = create_connection(merge_defaults(parameters)) local client = create_client(client_prototype, socket, commands) client.error = default_error_fn return client end -- ############################################################################ commands = { -- commands operating on the key space exists = command('EXISTS', { response = toboolean }), del = command('DEL'), type = command('TYPE'), rename = command('RENAME'), renamenx = command('RENAMENX', { response = toboolean }), expire = command('EXPIRE', { response = toboolean }), pexpire = command('PEXPIRE', { -- >= 2.6 response = toboolean }), expireat = command('EXPIREAT', { response = toboolean }), pexpireat = command('PEXPIREAT', { -- >= 2.6 response = toboolean }), ttl = command('TTL'), pttl = command('PTTL'), -- >= 2.6 move = command('MOVE', { response = toboolean }), dbsize = command('DBSIZE'), persist = command('PERSIST', { -- >= 2.2 response = toboolean }), keys = command('KEYS', { response = function(response) if type(response) == 'string' then -- backwards compatibility path for Redis < 2.0 local keys = {} response:gsub('[^%s]+', function(key) table.insert(keys, key) end) response = keys end return response end }), randomkey = command('RANDOMKEY', { response = function(response) if response == '' then return nil else return response end end }), sort = command('SORT', { request = function(client, command, key, params) --[[ params = { by = 'weight_*', get = 'object_*', limit = { 0, 10 }, sort = 'desc', alpha = true, } --]] local query = { key } if params then if params.by then table.insert(query, 'BY') table.insert(query, params.by) end if type(params.limit) == 'table' then -- TODO: check for lower and upper limits table.insert(query, 'LIMIT') table.insert(query, params.limit[1]) table.insert(query, params.limit[2]) end if params.get then if (type(params.get) == 'table') then for _, getarg in pairs(params.get) do table.insert(query, 'GET') table.insert(query, getarg) end else table.insert(query, 'GET') table.insert(query, params.get) end end if params.sort then table.insert(query, params.sort) end if params.alpha == true then table.insert(query, 'ALPHA') end if params.store then table.insert(query, 'STORE') table.insert(query, params.store) end end request.multibulk(client, command, query) end }), -- commands operating on string values set = command('SET'), setnx = command('SETNX', { response = toboolean }), setex = command('SETEX'), -- >= 2.0 psetex = command('PSETEX'), -- >= 2.6 mset = command('MSET', { request = mset_filter_args }), msetnx = command('MSETNX', { request = mset_filter_args, response = toboolean }), get = command('GET'), mget = command('MGET'), getset = command('GETSET'), incr = command('INCR'), incrby = command('INCRBY'), incrbyfloat = command('INCRBYFLOAT', { -- >= 2.6 response = function(reply, command, ...) return tonumber(reply) end, }), decr = command('DECR'), decrby = command('DECRBY'), append = command('APPEND'), -- >= 2.0 substr = command('SUBSTR'), -- >= 2.0 strlen = command('STRLEN'), -- >= 2.2 setrange = command('SETRANGE'), -- >= 2.2 getrange = command('GETRANGE'), -- >= 2.2 setbit = command('SETBIT'), -- >= 2.2 getbit = command('GETBIT'), -- >= 2.2 -- commands operating on lists rpush = command('RPUSH'), lpush = command('LPUSH'), llen = command('LLEN'), lrange = command('LRANGE'), ltrim = command('LTRIM'), lindex = command('LINDEX'), lset = command('LSET'), lrem = command('LREM'), lpop = command('LPOP'), rpop = command('RPOP'), rpoplpush = command('RPOPLPUSH'), blpop = command('BLPOP'), -- >= 2.0 brpop = command('BRPOP'), -- >= 2.0 rpushx = command('RPUSHX'), -- >= 2.2 lpushx = command('LPUSHX'), -- >= 2.2 linsert = command('LINSERT'), -- >= 2.2 brpoplpush = command('BRPOPLPUSH'), -- >= 2.2 -- commands operating on sets sadd = command('SADD', { response = toboolean }), srem = command('SREM', { response = toboolean }), spop = command('SPOP'), smove = command('SMOVE', { response = toboolean }), scard = command('SCARD'), sismember = command('SISMEMBER', { response = toboolean }), sinter = command('SINTER'), sinterstore = command('SINTERSTORE'), sunion = command('SUNION'), sunionstore = command('SUNIONSTORE'), sdiff = command('SDIFF'), sdiffstore = command('SDIFFSTORE'), smembers = command('SMEMBERS'), srandmember = command('SRANDMEMBER'), -- commands operating on sorted sets zadd = command('ZADD', { response = toboolean }), zincrby = command('ZINCRBY'), zrem = command('ZREM', { response = toboolean }), zrange = command('ZRANGE', { request = zset_range_request, response = zset_range_reply, }), zrevrange = command('ZREVRANGE', { request = zset_range_request, response = zset_range_reply, }), zrangebyscore = command('ZRANGEBYSCORE', { request = zset_range_byscore_request, response = zset_range_reply, }), zrevrangebyscore = command('ZREVRANGEBYSCORE', { -- >= 2.2 request = zset_range_byscore_request, response = zset_range_reply, }), zunionstore = command('ZUNIONSTORE', { -- >= 2.0 request = zset_store_request }), zinterstore = command('ZINTERSTORE', { -- >= 2.0 request = zset_store_request }), zcount = command('ZCOUNT'), zcard = command('ZCARD'), zscore = command('ZSCORE'), zremrangebyscore = command('ZREMRANGEBYSCORE'), zrank = command('ZRANK'), -- >= 2.0 zrevrank = command('ZREVRANK'), -- >= 2.0 zremrangebyrank = command('ZREMRANGEBYRANK'), -- >= 2.0 -- commands operating on hashes hset = command('HSET', { -- >= 2.0 response = toboolean }), hsetnx = command('HSETNX', { -- >= 2.0 response = toboolean }), hmset = command('HMSET', { -- >= 2.0 request = hash_multi_request_builder(function(args, k, v) table.insert(args, k) table.insert(args, v) end), }), hincrby = command('HINCRBY'), -- >= 2.0 hincrbyfloat = command('HINCRBYFLOAT', {-- >= 2.6 response = function(reply, command, ...) return tonumber(reply) end, }), hget = command('HGET'), -- >= 2.0 hmget = command('HMGET', { -- >= 2.0 request = hash_multi_request_builder(function(args, k, v) table.insert(args, v) end), }), hdel = command('HDEL', { -- >= 2.0 response = toboolean }), hexists = command('HEXISTS', { -- >= 2.0 response = toboolean }), hlen = command('HLEN'), -- >= 2.0 hkeys = command('HKEYS'), -- >= 2.0 hvals = command('HVALS'), -- >= 2.0 hgetall = command('HGETALL', { -- >= 2.0 response = function(reply, command, ...) local new_reply = { } for i = 1, #reply, 2 do new_reply[reply[i]] = reply[i + 1] end return new_reply end }), -- connection related commands ping = command('PING', { response = function(response) return response == 'PONG' end }), echo = command('ECHO'), auth = command('AUTH'), select = command('SELECT'), quit = command('QUIT', { request = fire_and_forget }), -- transactions multi = command('MULTI'), -- >= 2.0 exec = command('EXEC'), -- >= 2.0 discard = command('DISCARD'), -- >= 2.0 watch = command('WATCH'), -- >= 2.2 unwatch = command('UNWATCH'), -- >= 2.2 -- publish - subscribe subscribe = command('SUBSCRIBE'), -- >= 2.0 unsubscribe = command('UNSUBSCRIBE'), -- >= 2.0 psubscribe = command('PSUBSCRIBE'), -- >= 2.0 punsubscribe = command('PUNSUBSCRIBE'), -- >= 2.0 publish = command('PUBLISH'), -- >= 2.0 -- redis scripting eval = command('EVAL'), -- >= 2.6 evalsha = command('EVALSHA'), -- >= 2.6 script = command('SCRIPT'), -- >= 2.6 -- remote server control commands bgrewriteaof = command('BGREWRITEAOF'), config = command('CONFIG', { -- >= 2.0 response = function(reply, command, ...) if (type(reply) == 'table') then local new_reply = { } for i = 1, #reply, 2 do new_reply[reply[i]] = reply[i + 1] end return new_reply end return reply end }), client = command('CLIENT'), -- >= 2.4 slaveof = command('SLAVEOF'), save = command('SAVE'), bgsave = command('BGSAVE'), lastsave = command('LASTSAVE'), flushdb = command('FLUSHDB'), flushall = command('FLUSHALL'), shutdown = command('SHUTDOWN', { request = fire_and_forget }), slowlog = command('SLOWLOG', { -- >= 2.2.13 response = function(reply, command, ...) if (type(reply) == 'table') then local structured = { } for index, entry in ipairs(reply) do structured[index] = { id = tonumber(entry[1]), timestamp = tonumber(entry[2]), duration = tonumber(entry[3]), command = entry[4], } end return structured end return reply end }), info = command('INFO', { response = function(response) if string.find(response, '^# ') then return parse_info_new(response) end return parse_info(response) end }), }