From 431734f49a305f36c6628e22e471eca95f779036 Mon Sep 17 00:00:00 2001 From: mio Date: Fri, 1 Apr 2022 03:44:02 -0400 Subject: [PATCH] Add scheduled tasks --- docs/README.md | 6 +- examples/ramenkan/ramenkan.config.lua | 21 +++- examples/ramenkan/ramenkan.sample.servers.lua | 16 +++ itte.lua | 117 ++++++++++++++++-- 4 files changed, 144 insertions(+), 16 deletions(-) diff --git a/docs/README.md b/docs/README.md index 2c63f36..b8c9d47 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,7 +47,8 @@ - `itte_handlers`: most bots listen for some pre-defined code words (often known as "commands") and respond by calling a handler, or function to - handle the request accordingly. + handle the request accordingly. Handlers for scheduled tasks can also be + added. The module will refer to the keywords as codes to distinguish them from IRC commands sent to the server like `JOIN` or `PART`. Name the @@ -119,7 +120,8 @@ The `examples/` directory has a few demo bots to show how to use the module. - *hellobot*: a "hello world" example that greets users in several different languages when they type `!hello`. -- *ramenkan*: a bot that serves ramen. It has some custom config settings. +- *ramenkan*: a bot that serves ramen. It has some custom config settings and + scheduled tasks. - *hachi*: a bot inspired by tracery text expansion. Loads files ("foils") containing selection tables and creates handlers to return responses, one diff --git a/examples/ramenkan/ramenkan.config.lua b/examples/ramenkan/ramenkan.config.lua index 69e7525..858bc4c 100644 --- a/examples/ramenkan/ramenkan.config.lua +++ b/examples/ramenkan/ramenkan.config.lua @@ -439,16 +439,19 @@ end -- Serve meat-based ramen. function h.mramen(cxt, msg) + local recipients = { msg.reply_to } + -- Scheduled task recipients + if msg.recipients ~= nil then + recipients = msg.recipients + end -- 1% chance of special set local roll = math.random(1, 100) if roll == 77 then - irc.message(cxt, { msg.reply_to }, - string.gsub(itte_config.messages.special, "{{ramen}}", - ramen.make_ramen("meat_special"))) + irc.message(cxt, recipients, string.gsub(itte_config.messages.special, + "{{ramen}}", ramen.make_ramen("meat_special"))) else - irc.message(cxt, { msg.reply_to }, - string.gsub(itte_config.messages.serve, "{{ramen}}", - ramen.make_ramen("meat"))) + irc.message(cxt, recipients, string.gsub(itte_config.messages.serve, + "{{ramen}}", ramen.make_ramen("meat"))) end end @@ -474,4 +477,10 @@ function h.water(cxt, msg) end +-- Serve ramen at the interval specified in the config. +function h.th_ramen(cxt, task) + h.ramen(cxt, task) +end + + itte_handlers = h diff --git a/examples/ramenkan/ramenkan.sample.servers.lua b/examples/ramenkan/ramenkan.sample.servers.lua index 29b17cf..55df7d9 100644 --- a/examples/ramenkan/ramenkan.sample.servers.lua +++ b/examples/ramenkan/ramenkan.sample.servers.lua @@ -9,6 +9,14 @@ itte_servers = { auth_user = "USER", code_prefix = "!", admins = { USER = "PASSWORD" }, + tasks = { + ramen = { + interval = "daily", + time = "12:00", + handler = "th_ramen", + recipients = { "#ramenkan" }, + }, + }, }, casa = { host = "m455.casa", @@ -21,6 +29,14 @@ itte_servers = { auth_pass = "PASSWORD", code_prefix = "!", admins = { USER = "PASSWORD" }, + tasks = { + ramen = { + interval = "daily", + time = "17:00", + handler = "th_ramen", + recipients = { "#kitchen" }, + }, + }, }, tildechat = { host = "irc.tilde.chat", diff --git a/itte.lua b/itte.lua index 52518fe..f6be9fa 100644 --- a/itte.lua +++ b/itte.lua @@ -24,6 +24,7 @@ itte.confs = { itte.config = { debug = false, admin_handlers = { "connect", "quit", "reload", "servers", "join", "part" }, + task_handler_prefix = "th_", messages = { connect_success = "Connected to {{server}}.", help = "Service codes available: {{codes}}", @@ -56,6 +57,8 @@ itte.config = { redact = { "********" }, send = { "send" }, svrs_not_found = { "config", "Error: servers not found." }, + task_added = { "task", "Task `{{name}}` added." }, + task_activated = { "task", "Task `{{name}}` activated at {{ts}}." }, }, } @@ -78,6 +81,7 @@ itte.docs._h = {} -- Internal only: track application state itte.state = { connected = false, + timeout = 0.1, } itte.contexts = {} @@ -143,16 +147,19 @@ function itte.get_config(reload) if itte_handlers ~= nil then itte.handlers = itte_handlers end if itte_admins ~= nil then itte.admins = itte_admins end - -- If reloading, reconstruct the context tables. - -- This only works if the name name has not changed between reloads. if reload then + -- Reconstruct the context tables + -- This only works if the server name has not changed between reloads. for name, prop in pairs(itte.servers) do cxt_old = itte.contexts[name] itte.contexts[name] = prop itte.contexts[name].name = name itte.contexts[name].cmds = itte.get_commands(prop) itte.contexts[name].con = cxt_old.con - itte.contexts[name].con:settimeout(0.5) + itte.contexts[name].con:settimeout(itte.state.timeout) + + -- Reconstruct the task coroutines + itte.add_tasks(itte.contexts[name]) end end end @@ -307,7 +314,7 @@ function itte.init_socket(name, svr) context.con = ssl.wrap(context.con, con_params) context.con:dohandshake() -- Set a short timeout to be non-blocking - context.con:settimeout(0.1) + context.con:settimeout(itte.state.timeout) -- Set context and global states context.state.connected = true @@ -385,6 +392,7 @@ function itte.disconnect_server(name) util.debug(itte.config.debugs.exit[1], itte.config.debugs.exit[2], itte.config.debug) itte.state.connected = false + os.exit() end end @@ -767,6 +775,12 @@ function itte._h.help(cxt, msg) end local custom_h = util.table_keys(itte.handlers) + -- Remove task handlers + for h = 1, #custom_h do + if string.find(custom_h[h], itte.config.task_handler_prefix) == 1 then + custom_h[h] = nil + end + end local codes = cxt.code_prefix .. table.concat(custom_h, ", " .. cxt.code_prefix) -- Core service codes are shown only to admins @@ -810,7 +824,7 @@ end -- --------------------------------------------------------------------------- --- Service code mapping and runtime +-- Service code and taskd task mapping -- --------------------------------------------------------------------------- itte.docs.listen = [[ (name_str, data_str) @@ -838,9 +852,12 @@ function itte.listen(name, str) util.debug(itte.config.debugs.listen[1], str, itte.config.debug) end + -- Ignore calls to task handlers + if (string.find(msg.code, itte.config.task_handler_prefix) == 1) then + do return end -- Check for the service code in the functions table before attempting to -- call the function. - if util.has_key(itte._h, msg.code) then + elseif util.has_key(itte._h, msg.code) then itte._h[msg.code](cxt, msg) -- Check the handlers table elseif util.has_key(itte.handlers, msg.code) then @@ -872,6 +889,83 @@ function itte.add_listener(cxt) end +itte.docs.add_tasks = [[ (context_table) + Create coroutines for tasks. + ]] +function itte.add_tasks(cxt) + -- Exit function if there are no tasks for the context + if (cxt.tasks == nil) or (cxt.tasks == {}) then + do return end + end + + -- Construct a task coroutine and call its handler function when resumed + local co = function(name, task) + if util.has_key(itte.handlers, task.handler) then + local ts = os.date("%Y-%m-%d %H:%M:%S", os.time()) + local task_str = string.gsub(itte.config.debugs.task_activated[2], + "{{name}}", name) + task_str = string.gsub(task_str, "{{ts}}", ts) + util.debug(itte.config.debugs.task_activated[1], task_str, + itte.config.debug) + itte.handlers[task.handler](cxt, task) + -- Suspend coroutine to be reactivated later + coroutine.yield(co) + task.done = true + end + end + + -- Initialise task coroutine + for name, task in pairs(cxt.tasks) do + task.co = coroutine.create(co) + task.done = false + util.debug(itte.config.debugs.task_added[1], + string.gsub(itte.config.debugs.task_added[2], "{{name}}", name), + itte.config.debug) + end +end + + +itte.docs.check_tasks = [[ (context_table) + Compare task times to the current time and resume a task's coroutine if + the task interval or time match the current time. + ]] +function itte.check_tasks(cxt) + -- Exit function if there are no tasks for the context + if (cxt.tasks == nil) or (cxt.tasks == {}) then + do return end + end + + local dt = util.split_str(os.date("%w %Y %m %d %H %M", os.time())) + -- Support preset minute intervals + local interval_min = { 5, 10, 15, 20, 30 } + for name, task in pairs(cxt.tasks) do + local task_min = string.sub(task.time, string.find(task.time, ":") + 1, + string.find(task.time, ":") + 2) + if (util.has_key(interval_min, tonumber(task.interval:sub(1, -2))) and + tonumber(dt[6]) % tonumber(task.interval:sub(1, -2)) == 0) or + (task.interval == "hourly" and task_min == dt[6]) or + (task.interval == "daily" and task.time == dt[5] .. ":" .. dt[6]) or + (task.interval == "weekly" and task.weekday == dt[1] and + task.time == dt[5] .. ":" .. dt[6]) or + (task.interval == "monthly" and task.day == dt[4] and + task.time == dt[5] .. ":" .. dt[6]) + then + -- Run only once per interval + while not task.done do + coroutine.resume(task.co, name, task) + end + else + -- Reset the done flag after running task + task.done = false + end + end +end + + +-- --------------------------------------------------------------------------- +-- Runtime +-- --------------------------------------------------------------------------- + itte.docs.run = [[ () Run the client. ]] @@ -882,12 +976,19 @@ function itte.run() itte.connect_server(name) end - while itte.state.connected do + if itte.state.connected then for _, cxt in pairs(itte.contexts) do - itte.add_listener(cxt) + itte.add_tasks(cxt) end end + while itte.state.connected do + for _, cxt in pairs(itte.contexts) do + itte.add_listener(cxt) + itte.check_tasks(cxt) + end + + end end