Rewrite module in Lua

trunk 1.0
mio 1 year ago
parent 8d262f2fb1
commit 9005c0fba5

11
.gitignore vendored

@ -1,8 +1,3 @@
__pycache__/
*.py[cod]
*$py.class
*.swp
nohup.out
*.config.yml
*.log
*.servers.lua
examples/itte*.lua
!sample.servers.lua

@ -1,23 +1,39 @@
# itte
A very basic Python IRC bot script.
Mini IRC bot module in Lua.
## Example: ramenkan
Currently supported:
- Authentication via SASL (plain) or Nickserv
- Joining multiple servers
- Config reload
## Requirements
- [Lua 5.x](https://www.lua.org/)
- [luasocket](https://w3.impa.br/~diego/software/luasocket/)
- [luasec](https://github.com/brunoos/luasec)
- Install dependencies: `pip install Mastodon.py pyyaml`
- Copy the `ramenkan/config.sample.yml` as `ramenkan/default.config.yml` and
change the settings as applicable.
## Usage
See the bot files in the `examples/` directory for usage notes.
## Example: ramenkan
- Install Lua and other dependencies, e.g. for Alpine:
`apk add lua-socket lua-sec`
- Run:
- Copy the `itte*.lua` files to the `examples/` directory. Copy the
`examples/sample.servers.lua` as `ramenkan.servers.lua` and change the server
settings as applicable.
```
chmod +x ramenkan.py
nohup python3 ramenkan.py >/dev/null 2>&1 &
```
- Run: `nohup lua /path/to/examples/ramenkan.lua >/dev/null 2>&1 &`
## License
BSD
BSD-3.0

@ -0,0 +1,500 @@
-- ---------------------------------------------------------------------------
-- Ramen data
-- ---------------------------------------------------------------------------
-- [[
-- This is sample data and could be split into another file.
-- ]]
local ramen = {}
ramen.noodle_broth_types = {
"thick noodles in rich",
"medium-thick noodles in mild",
"thin noodles in light",
}
ramen.noodle_shapes = {
"curly", "flat", "straight",
}
ramen.broths = {
"miso", "spicy miso", "soy sauce", "grilled soy sauce and Rishiri kelp",
"salt", "salt and soy sauce",
}
-- niboshi: dried little sardines
-- katsuoboshi, konbu: skipjack tuna, kelp
-- gyokai: anchovies/shrimp/squid/seaweed
-- miso and truffle: ramen by chef Ryu Takahashi
ramen.broths_meat = {
"Kobe beef bone", "tonkotsu", "pork bone and chicken",
"pork bone and niboshi", "pork bone, chicken and seafood",
"pork bone, chicken, tuna and kelp", "pork bone and soy sauce", "chicken",
"chicken, katsuoboshi and konbu", "fish", "gyokai", "gyokai tonkotsu",
"roasted tuna", "miso and truffle", "milk", "curry and milk",
}
-- wagyu: Japanese beef known for its higher fat percentage
-- kamaboko/surimi: fish paste
-- narutomaki: fish paste with a swirling pattern
-- kakuni: cubed braised pork
-- chashu: Japanese-style barbecued pork
ramen.meats = {
"Kobe beef fillet", "Kobe beef sirloin", "wagyu", "teppanyaki beef",
"beef shank", "chicken breast", "chicken meatballs", "teppanyaki chicken",
"crab", "crispy duck", "filet mignon", "kamaboko", "narutomaki", "kakuni",
"chashu", "pork confit", "pork cutlet", "pork belly", "pork shoulder",
"shrimp", "sea urchin", "scallops", "squid",
}
-- beni shoga: pickled ginger
-- menma: fermented bamboo shoots
-- kikurage: a type of fungi, known in some cultures as "wood ear"
-- karashi takana: spicy pickled mustard greens
-- negi: Welsh onion
ramen.toppings = {
"bean sprouts", "chili flakes", "sweet corn", "grated garlic",
"roasted garlic chips", "beni shoga", "bamboo shoots", "menma",
"dried mushrooms", "shiitake mushrooms", "kikurage", "karashi takana",
"negi", "white onions", "spring onions", "sichuan peppers", "nori", "wakame",
"sauerkraut", "sesame seeds", "scallions", "boiled spinach",
}
-- ajitama: egg marinated in soy sauce
ramen.toppings_egg = {
"egg yolk", "seasoned boiled egg", "ajitama",
}
ramen.sauce_verbs = {
"garnished", "sprinkled", "sprinkled lightly", "sprinkled liberally",
"drizzled", "drizzled lightly", "drizzled artfully", "brimming",
}
ramen.spice_verbs = {
"seasoned", "sprinkled", "sprinkled lightly", "sprinkled liberally",
}
-- rayu: chili oil
-- mayu: garlic oil
-- dashi: fish/seaweed stock
-- ponzu: citrus sauce
-- shichimi: spice blend
ramen.sauces = {
"butter", "rayu", "chili sauce", "mayu", "spicy garlic and ginger oil",
"sesame oil", "dashi", "ponzu",
}
ramen.spices = {
"black pepper", "white pepper", "chili peppers", "shichimi",
}
ramen.tapas = {
"edamame tossed in sea salt", "tofu nuggets", "picked daikon",
"cucumber salad", "seaweed salad",
}
ramen.tapas_meat = {
"gyoza", "karaage", "tako wasabi", "takoyaki", "corn and shrimp salad",
"crab salad", "beef enoki rolls",
}
ramen.sets = {
"Gunma konnyaku ramen in soy sauce broth",
"miso ramen with vegetable broth, cabbage and bean sprouts",
"ramen with shiitake mushrooms, kombu and sesame oil",
"ramen with soybean pork slices, kikurage, bamboo shoots and scallions",
"barley miso ramen with roasted vegetables",
}
ramen.sets_meat = {
"Korean kimchi ramyeon with tteok",
"Vancouver cold bonito beer broth ramen with egg whites",
"Hakata tonkotsu ramen with pickled mustard greens",
"Hakodate shio ramen with kelp, crab, shrimp, sea urchin and scallops",
"Hakodate shrimp ramen with a hard-boiled egg and white onions",
"Kagoshima tonkotsu ramen with chashu, dried sardines, kelp and scallions",
"Kitakata niboshi-tonkotsu ramen with chashu, naruto and spring onions",
"Kumamoto tonkotsu ramen with chashu, raw eggs, scallions and garlic chips",
"Muroran curry ramen with chashu, wakame and bean sprouts",
"Nagasaki champon with fried pork, scallions and seasonal greens",
"Osaka Kobe beef sirloin ramen with a soft-boiled egg and arugula",
"Sapporo miso ramen with scallops, sweet corn and bean sprouts",
"Tokyo ramen in chicken broth with pork, menma, a boiled egg and nori",
"Wakayama shoyu-tonkotsu ramen with pork slices, naruto and scallions",
"Yokohama ramen with chashu, spinach, a soft-boiled egg and negi",
}
-- Links to photos and resources
ramen.links = {
-- Photos of ramen vending machines
-- Ticket machines
"https://c2.staticflickr.com/6/5077/5902028877_7d8c65b23f_b.jpg",
"https://c1.staticflickr.com/3/2455/5777431350_36e147e719_z.jpg",
"https://i.pinimg.com/originals/72/08/1e/72081e68ff0989d54fd24a86fcac6f2b.jpg",
"https://i.pinimg.com/originals/9f/74/92/9f7492b7c456ac7ba350498a50c74b23.jpg",
"https://c1.staticflickr.com/9/8157/7105010129_cd4b0d7e27_c.jpg",
"https://c1.staticflickr.com/8/7600/27418644850_2c70342734_c.jpg",
"https://c3.staticflickr.com/3/2049/2229742287_8df3e823a9_z.jpg",
"https://c1.staticflickr.com/1/101/308523474_4295a27326_z.jpg",
"https://media-cdn.tripadvisor.com/media/photo-s/0b/b6/27/f6/ramen-shop-menu-banner.jpg",
"https://abroadabroad2011.files.wordpress.com/2013/03/sam_0040.jpg",
"https://c1.staticflickr.com/6/5753/22803130417_ba86ebb4fa_c.jpg",
"https://c1.staticflickr.com/6/5077/5902028877_7d8c65b23f_z.jpg",
"https://c1.staticflickr.com/8/7504/15210528573_2612010fc3_c.jpg",
"https://s3-media4.fl.yelpcdn.com/bphoto/iUHJczm_--xNqWEUzDS69Q/o.jpg",
"https://thewholeworldornothing.com/wp-content/uploads/2017/01/vending-machine-waiter.jpg",
"https://thebrunchingbooth.files.wordpress.com/2014/07/img_42681.jpg",
"https://c1.staticflickr.com/8/7426/16587852342_0cbfa4a63c_c.jpg",
"https://c1.staticflickr.com/9/8422/7535359482_b5537bbde0_c.jpg",
"https://c1.staticflickr.com/5/4037/4530351457_8666286b56_z.jpg",
"https://c1.staticflickr.com/1/97/244236130_0c22679f97_z.jpg",
"https://c1.staticflickr.com/4/3479/3465112347_1be2868ff0_z.jpg",
"https://1.bp.blogspot.com/_W-pG7tUmJk0/TDQ-J_vz9sI/AAAAAAAACac/xYjAvl8ZGOY/s1600/shinjuku_ramen_02.jpg",
"https://thefoodieflight.files.wordpress.com/2015/10/img_3749.jpg",
"https://static1.squarespace.com/static/57ad3b51c534a528e26a2e93/t/57f91e3be6f2e18cf1d11c82/1476472671797/",
"https://c1.staticflickr.com/1/166/417127513_e063ccf80a_z.jpg",
-- Instant cup noodles
"http://www.toxel.com/wp-content/uploads/2009/06/vendingmachine04.jpg",
"https://c1.staticflickr.com/3/2233/2347915323_9e9e2264a3_z.jpg",
"https://c1.staticflickr.com/4/3818/10186967173_237d38b86f_c.jpg",
"https://c1.staticflickr.com/8/7221/7175446233_076264a2b4_c.jpg",
"https://c7.alamy.com/comp/BCDJRG/a-cup-noodle-vending-machine-instant-ramen-museum-osaka-japan-1-december-BCDJRG.jpg",
-- Ramen can
"https://c1.staticflickr.com/4/3625/3374051074_641b371d23_z.jpg",
"https://c4.staticflickr.com/4/3214/2911821453_78c6178f54_z.jpg",
"https://smallbiztrends.com/wp-content/uploads/2015/06/P1020817-728x485.jpg",
"https://c1.staticflickr.com/9/8440/7745533776_7b9990be82_c.jpg",
"https://c1.staticflickr.com/3/2268/4507516049_e7aa354489_z.jpg",
-- Various ramen-related links
-- Places and shops
"Ramen, Soul Food Staple - " ..
"https://www3.nhk.or.jp/nhkworld/en/food/articles/51.html",
"Shin-Yokohama Ramen Museum, Yokohama - " ..
"http://www.raumen.co.jp/english/",
"Ramen Street, Tokyo - " ..
"https://en.wikipedia.org/wiki/Ramen_street",
"Sapporo Ramen Republic - " ..
"http://www.sapporo-esta.jp/ramen",
"Ramen DB, a directory of ramen shops in Japan - " ..
"https://ramendb.supleks.jp/",
"Ramen Adventures, a ramen shop review blog - " ..
"http://www.ramenadventures.com/",
-- Recipes
"15 Fantastic Ramen Recipes - " ..
"https://www3.nhk.or.jp/nhkworld/en/food/articles/131.html",
"Chef Rika's Miso Ramen - " ..
"https://www3.nhk.or.jp/nhkworld/en/ondemand/video/9999434/",
"Chef Rika's Shoyu Ramen - " ..
"https://www3.nhk.or.jp/nhkworld/en/ondemand/video/9999299/",
-- Instant noodles
"Momofuku Ando, inventor of instant ramen (1958) - " ..
"https://www.nissin.com/en_jp/about/founder/",
"Instant cup noodles (1971) - " ..
"https://en.wikipedia.org/wiki/Cup_Noodles",
"Instant noodles as emergency food in disaster relief - " ..
"https://instantnoodles.org/en/activities/support.html",
"Samyang, the first Korean instant ramen (1963) - " ..
"https://en.wikipedia.org/wiki/Samyang_ramen",
"The Ramen Rater, an instant ramen blog - " ..
"https://www.theramenrater.com/",
-- Ramen news
"Japan's leading ramen chain Ippudo goes vegan - " ..
"https://asia.nikkei.com/Business/Food-Beverage/Japan-s-leading-ramen-chain-Ippudo-goes-vegan",
-- Ramen in pop culture
"Ramen Manga, comic strips in Japanese about ramen - " ..
"https://www.ramenbank.com/manga/",
"Flower Boy Ramen Shop, romantic comedy drama (2011) - " ..
"https://en.wikipedia.org/wiki/Flower_Boy_Ramen_Shop",
"The Ramen Girl, romantic comedy film (2008) - " ..
"https://en.wikipedia.org/wiki/The_Ramen_Girl",
"Ms. Koizumi Loves Ramen Noodles, manga series (2013-) - " ..
"https://en.wikipedia.org/wiki/Ms._Koizumi_Loves_Ramen_Noodles",
"Muteki Kanban Musume, manga series (2002-2006) - " ..
"https://en.wikipedia.org/wiki/Muteki_Kanban_Musume",
"Detective Conan - Ramen So Good, It's to Die For, anime episode (2012) - " ..
"https://www.detectiveconanworld.com/wiki/Ramen_So_Good,_It%27s_to_Die_For",
"Ramen Teh, film (2018) - " ..
"https://en.wikipedia.org/wiki/Ramen_Teh",
"Ramen yori taisetsuna mono, documentary (2013) - " ..
"https://www.imdb.com/title/tt2945642/",
"Ramen Cake dessert looks just like the real thing, is probably just as bad for you - " ..
"https://soranews24.com/2013/10/24/ramen-cake-dessert-looks-just-like-the-real-thing-is-just-as-terrible-for-you/",
-- Audio
"Japan Eats! Episode 155 - Oisa Ramen (2019) - " ..
"https://heritageradionetwork.org/podcast/oisa-ramen-i-am-just-a-mom-who-cares-to-give-her-best",
-- Videos
"What Owning a Ramen Restaurant in Japan is Like - " ..
"https://www.youtube.com/watch?v=gmIwxqdwgrI",
"Begin Japanology - Ramen - " ..
"https://www.youtube.com/watch?v=RosUc9UVuos",
"Japanology Plus - Ramen - " ..
"https://www.youtube.com/watch?v=K2Jzt04QY7A",
"In Search of the Perfect Bowl of Ramen - " ..
"https://www.youtube.com/watch?v=bhiFtP9qVYc",
"Rahaku TV (The Shin-Yokohama Ramen Museum Youtube Channel) - " ..
"http://www.youtube.com/rahakutv",
"The History of Ramen Yamagoya - " ..
"https://www.youtube.com/watch?v=8NBwKbT5QSQ",
"Ramen Daruma - " ..
"https://www.youtube.com/watch?v=zkhDYTR789w",
"The God of Ramen, trailer (2013) - " ..
"https://www.youtube.com/watch?v=mtwunTGnnR4",
"Ramen Heads, trailer (2017) - " ..
"https://www.youtube.com/watch?v=u61b5R_S4TM",
"Ramenomania - Noodles, Asia's Second Obsession - " ..
"https://www.youtube.com/watch?v=tfW89yAoTNs",
"Noodle Me - " ..
"https://www.youtube.com/watch?v=r9jLJRZ6gnc",
"Visio Documentary - Fat Ramen - " ..
"https://www.youtube.com/watch?v=o78fy4rJFCw",
"Ramen Talk and Q+A With Chef Ryu Takahashi and Chef Matt Kimura - " ..
"https://www.youtube.com/watch?v=tMvArDklmGU",
}
-- ---------------------------------------------------------------------------
-- Ramen functions
-- ---------------------------------------------------------------------------
-- Load the util module early to use some helper functions.
local util = require("itteutil")
function ramen.prep_noodles(mode)
local noodles = "ramen in a"
-- Dish type, noodle type linked to broth density
local d_roll = math.random(1, 10)
-- 20% chance of tsukemen, 40% chance of noodle thickness description
if d_roll <= 2 then
noodles = "tsukemen from a"
elseif (d_roll >= 3) and (d_roll <= 6) then
noodles = util.pick(itte_config.ramen.noodle_broth_types)[1]
end
-- Broth type
local broths = itte_config.ramen.broths
if mode == "meat" then
broths = itte_config.ramen.broths_meat
end
return noodles .. " " .. util.pick(broths)[1] .. " broth"
end
function ramen.prep_toppings(mode)
-- 2-5 toppings
local t_roll = math.random(2, 5)
local toppings = ""
-- Meat: reduce other toppings to max 3 ingredients
-- 25% chance of egg included
if mode == "meat" then
t_roll = math.random(2, 4)
toppings = util.pick(itte_config.ramen.meats)[1] .. ", "
local e_roll = math.random(1, 4)
if e_roll == 1 then
local egg = util.pick(itte_config.ramen.toppings_egg)[1]
toppings = toppings .. "{{toppings}} and " .. util.a_or_an(egg) .. " " ..
egg
end
end
local tops = util.pick(itte_config.ramen.toppings, t_roll)
-- Check for egg and insert toppings between meat and egg
if (util.is_substr(toppings, "and a")) then
toppings = string.gsub(toppings, "{{toppings}}", table.concat(tops, ", "))
toppings = string.gsub(toppings, " ", " ")
-- 2 toppings, no egg
elseif t_roll == 2 then
toppings = toppings .. table.concat(tops, " and ")
-- Multiple toppings, no egg
else
local last = tops[#tops]
table.remove(tops)
toppings = toppings .. table.concat(tops, ", ") .. " and " .. last
end
return toppings
end
function ramen.prep_condiments()
-- 20% chance of spices
local c_roll = math.random(1, 10)
local cond = util.pick(itte_config.ramen.sauce_verbs)[1] .. " with " ..
util.pick(ramen.sauces)[1]
if c_roll >= 9 then
cond = util.pick(itte_config.ramen.spice_verbs)[1] .. " with " ..
util.pick(ramen.spices)[1]
end
return cond
end
function ramen.prep_tapas(mode)
local tapas = util.pick(itte_config.ramen.tapas)[1]
if mode == "meat" then
tapas = util.pick(itte_config.ramen.tapas_meat)[1]
end
return "served with " .. tapas
end
function ramen.make_ramen(mode)
local r = ""
if mode == "veg" then
r = ramen.prep_noodles("veg") .. " with " ..
ramen.prep_toppings("veg") .. ", " .. ramen.prep_condiments()
elseif mode == "meat" then
r = ramen.prep_noodles("meat") .. " with " ..
ramen.prep_toppings("meat") .. ", " .. ramen.prep_condiments()
elseif mode == "veg_special" then
r = util.pick(itte_config.ramen.sets)[1]
elseif mode == "meat_special" then
r = util.pick(itte_config.ramen.sets_meat)[1]
end
-- 50% chance of serving with tapas
local t_roll = math.random(1, 2)
if (t_roll == 1) and (util.is_substr(mode, "veg")) then
r = r .. ". " .. util.first_upper(ramen.prep_tapas("veg")) .. "."
elseif (t_roll == 1) and (util.is_substr(mode, "meat")) then
r = r .. ". " .. util.first_upper(ramen.prep_tapas("meat")) .. "."
else
r = r .. "."
end
return util.first_upper(r)
end
-- ---------------------------------------------------------------------------
-- Config
-- ---------------------------------------------------------------------------
-- [[
-- `itte_config`
-- Add custom settings and override the default config settings here.
-- Settings need to be appened to this variable to be collected by the reload
-- function.
-- ]]
itte_config = {
debug = true,
notify_errors = true,
messages = {
help = "一、二、三、らーめん缶! Hello, I am a ramen vending machine. " ..
"Please type a code for service: {{codes}} " ..
"Support: +81 012-700-1MIO どうぞめしあがれ。ヾ(^ω^ )",
join = ". . . aaand we're in! (*´∇`*)",
part = "Okay, okay, I'll leave. (´・ω・`)",
ping = "ポーン!",
quit = "noodling off",
reload = "Ramen matrix reloaded! (´∀`)",
},
errors = {
no_perm = "Can ... not, {{user}}. Sorry. " ..
"I'll get in hot water for that! Σ(°△°|||)︴",
notify_no_perm = "{{user}} attempted to use service code: {{code}}",
ns_auth_failed = "Error: Nickserv identification failed.",
sasl_auth_failed = "Error: SASL authentication failed.",
unknown_code = "What's that? Doesn't sound very noodly to me. (´・ω・`)?",
},
}
-- Custom variables
itte_config.ramen = ramen
itte_config.messages.serve = "{{ramen}} ⊂(・▽・⊂)"
itte_config.messages.special = "**SPECIAL!!!** {{ramen}} " ..
"☆*:・゚ o(^ ∀ ^ o)"
itte_config.messages.water = {
"\x01ACTION happily pours the hot liquid into a bowl of noodles " ..
"and offers it to {{user}}\x01",
"Cheers! 自o^_^ ",
"Water Level [/////////] 200% - Thanks! (^▽^ )",
"Thanks. Some people say I have a CRABby tempeRAMENt. I wonder why. " ..
"´-` ",
"Q. What do you call noodles made from the core of a tree? " ..
"A. DuRAMEN. *ba dum tss* ┐('∀`)┌",
"Q. What do you call noodles eaten on bulletin boards? " ..
"A. FoRAMEN. *ba dum tss* ┐('∀`)┌",
"Q. What kind of noodles can be found at TV stations? " ..
"A. CameRAMEN. *ba dum tss* ┐('∀`)┌",
"Ramen time anytime! 自o(´▽` )/",
}
-- ---------------------------------------------------------------------------
-- Handlers
-- ---------------------------------------------------------------------------
-- Load the module.
local irc = require("itte")
--[[
-- `itte_handlers`
-- Define any custom handlers in a new table, then assign the table to it.
-- Handlers can also be added to it directly e.g. `itte_handlers.hello()`.
-- Handler names should correspond to the service code name, e.g. a function
-- `itte_handlers.hello()` will be called within chat as
-- `[command_prefix]hello`, as in `!hello`.
--]]
local h = {}
-- Serve ramen.
function h.ramen(cxt, msg)
-- 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("veg_special")))
else
irc.message(cxt, { msg.reply_to },
string.gsub(itte_config.messages.serve, "{{ramen}}",
ramen.make_ramen("veg")))
end
end
-- Serve meat-based ramen.
function h.mramen(cxt, msg)
-- 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")))
else
irc.message(cxt, { msg.reply_to },
string.gsub(itte_config.messages.serve, "{{ramen}}",
ramen.make_ramen("meat")))
end
end
-- Show help message.
function h.rollcall(cxt, msg)
irc._h.help(cxt, msg)
end
-- Reply with a random joke or link.
function h.water(cxt, msg)
local roll = math.random(1, 2)
local resp = util.pick(itte_config.messages.water)[1]
if roll == 2 then
resp = util.pick(itte_config.ramen.links)[1]
end
-- Replace user placeholder if present
if util.is_substr(resp, "{{user}}") then
resp = string.gsub(resp, "{{user}}", msg.sender)
end
irc.message(cxt, { msg.reply_to }, resp)
end
itte_handlers = h

@ -0,0 +1,26 @@
-- Initialise module.
local ramenkan = require("itte")
-- [[
-- Set the config file paths.
--
-- confs.prefix: the keyword prefix for the config files. When `run()` is
-- called, by default the client will look for config files as
-- [prefix].config.lua and [prefix].servers.lua.
--
-- The `[prefix].config.lua` should contain the `itte_config` and
-- `itte_handlers` variables, and `[prefix].servers.lua` should contain the
-- `itte_servers` variable. The variables can be stored in the same file, but
-- if sharing the source, it is recommended to keep them in separate files to
-- help redact server connection details like passwords.
--
-- `confs.config` and `confs.server` can be used to pass in the file names
-- individually if they are named differently from the default.
--
-- Either set confs.prefix, *or* a combination of confs.config and
-- confs.server, but not both.
-- ]]
ramenkan.confs.prefix = "ramenkan"
-- Call the run function.
ramenkan.run()

@ -0,0 +1,24 @@
-- ---------------------------------------------------------------------------
-- Server configuration
-- ---------------------------------------------------------------------------
-- `itte_servers`
-- Below is a sample server configuration.
-- If the server does not support CAP negotiation (used for SASL
-- authentication), set: cap = nil
-- `auth_type` options: nil, "pass", "sasl", "nickserv"
-- ]]
itte_servers = {
server_name = {
host = "irc.example.tld",
port = 6667,
channels = { "#channel-name" },
cap = { "sasl" },
nick = "botnick",
auth_type = nil,
auth_user = "botuser",
auth_pass = "password",
code_prefix = "!",
admins = { adminuser = "password" },
},
}

@ -0,0 +1,638 @@
-- ---------------------------------------------------------------------------
-- Itte
-- ---------------------------------------------------------------------------
--[[
-- The main module file.
--]]
-- ---------------------------------------------------------------------------
-- Variables
-- ---------------------------------------------------------------------------
local itte = {}
-- Store config names
itte.confs = {
prefix = nil,
config = ".config.lua",
servers = ".servers.lua",
}
-- Default application settings
itte.config = {
debug = false,
notify_errors = false,
messages = {
help = "Service codes available: {{codes}}",
join = "We're in.",
part = "The bot is greater than the sum of its parts.",
ping = "Pong!",
quit = "Goodbye.",
reload = "Splines recticulated.",
},
errors = {
no_perm = "I'm sorry, {{user}}. I'm afraid I can't do that.",
notify_no_perm = "{{user}} attempted to use service code: {{code}}",
ns_auth_failed = "Error: Nickserv identification failed.",
sasl_auth_failed = "Error: SASL authentication failed.",
unknown_code = "I don't understand what you just said.",
},
}
-- Store server info
itte.servers = {}
-- Store custom handlers
itte.handlers = {}
-- Internal only: store core handlers
itte._h = {}
-- Internal only: store function docstrings
itte.docs = {}
itte.docs._h = {}
-- Internal only: track application state
itte.state = {
connected = false,
connections = {},
}
-- ---------------------------------------------------------------------------
-- Functions
-- ---------------------------------------------------------------------------
local util = require("itteutil")
itte.docs.help = [[ ([func_str])
Given a function name, print a corresponding description. If no function name
is provided, print all function descriptions.
]]
function itte.help(name)
util.get_docs(itte.docs, name)
end
itte.docs.get_config = [[ ()
Load the config file.
]]
function itte.get_config()
-- If prefix is not set, try custom file names or defaults
if itte.confs.prefix == nil then
util.source_file(itte.confs.config)
-- Avoid duplicate load if config and servers are set to the same file
if (itte.servers == nil) or (itte.confs.servers ~= itte.conf.config) then
util.source_file(itte.confs.servers)
end
-- Prefix is set, use prefixed file names
else
util.source_file(itte.confs.prefix .. itte.confs.config)
util.source_file(itte.confs.prefix .. itte.confs.servers)
end
-- Check for server info
if itte.servers ~= nil then
itte.servers = itte_servers
else
util.debug("config", "Error: servers not found.", itte.config.debug)
do return end
end
if itte_config ~= nil then itte.config = itte_config end
if itte_handlers ~= nil then itte.handlers = itte_handlers end
end
itte.docs.get_commands = [[ (server_table)
Return a table of IRC commands to parse and their responses.
]]
function itte.get_commands(svr)
local default = {
-- CAP and SASL negotiation commands
cap_ls = {
-- No check, command is sent immediately after socket connect
-- before the USER/NICK greet
check = nil,
resp = "CAP LS 302",
},
cap_req = {
-- Escape the * in "CAP * LS ..."
check = "CAP(.*)LS(.*)sasl",
resp = "CAP REQ ",
},
auth_plain = {
-- The format of the CAP ACK varies among servers
-- e.g. "CAP [user] ACK ...", "CAP * ACK ..."
check = "CAP(.*)ACK(.*)sasl",
resp = "AUTHENTICATE PLAIN",
},
auth_pass = {
check = "AUTHENTICATE +",
resp = "AUTHENTICATE ",
},
cap_end = {
-- Text of the 903 success message varies among servers
-- e.g. "903 * :Authentication successful",
-- "903 [user] :SASL authentication successful"
check = "903(.*)successful",
resp = "CAP END",
},
-- Nickserv commands
ns_identify = {
check = "NickServ IDENTIFY",
resp = "NickServ IDENTIFY " .. svr.auth_user .. " " ..
tostring(svr.auth_pass),
},
ns_identify_pass = {
-- No response needed
check = "You are now identified",
resp = nil,
},
-- Main commands
join = {
-- 001 is a welcome message
-- Cannot join earlier on some servers if not registered
check = "001 " .. svr.nick,
resp = "JOIN ",
},
nick = {
check = "NOTICE",
resp = "NICK " .. svr.nick,
},
pass = {
-- No check, command is sent immediately after socket connect
-- before the USER/NICK greet
check = nil,
resp = "PASS " .. svr.auth_user .. ":" .. tostring(svr.auth_pass),
},
part = {
check = nil,
resp = "PART ",
},
ping = {
check = "PING ",
resp = "PONG ",
},
privmsg = {
check = "PRIVMSG(.*):!",
resp = "PRIVMSG ",
},
quit = {
check = nil,
resp = "QUIT :",
},
user = {
check = "NOTICE",
resp = "USER " .. svr.auth_user .. " 0 * " .. svr.auth_user,
},
}
return default
end
itte.docs.send_command = [[ (context_table, cmd_str)
Format a IRC command string to send through a socket connection.
]]
function itte.send_command(con, str)
con:send(str .. "\r\n")
util.debug("send", str, itte.config.debug)
end
itte.docs.message = [[ (context_table, users_table, message_str)
Format and send a private message to a table of users.
]]
function itte.message(cxt, users, str)
for n = 1, #users do
itte.send_command(cxt.con, cxt.cmds.privmsg.resp .. users[n] .. " :" ..
str)
end
end
itte.docs.connect_server = [[ (server_table)
Initialise a socket connection to a server and return a context table.
]]
function itte.connect_server(svr)
-- Load server context
local context = svr
context.cmds = itte.get_commands(svr)
context.state = {
cap_greeted = false,
cap_ls = false,
cap_checked = false,
ns_checked = false,
authed = false,
}
-- Connect
local sock = require("socket")
local ssl = require("ssl")
context.con = sock.tcp()
local con_params = {
mode = "client",
protocol = "tlsv1_2",
verify = "none",
options = "all",
}
util.debug("conn", "Connecting to " .. svr.host .. "/" .. svr.port ..
" ...", itte.config.debug)
context.con:connect(svr.host, svr.port)
context.con = ssl.wrap(context.con, con_params)
context.con:dohandshake()
context.state.connected = true
return context
end
itte.docs.negotiate_cap = [[ (context_table, data_str)
Negotiate client capabilities and authenticate with SASL.
]]
function itte.negotiate_cap(cxt, str)
-- Some servers have a delay between ls and req
if (not cxt.state.cap_greeted) and (not cxt.state.cap_checked) and
(not cxt.state.cap_ls) then
itte.send_command(cxt.con, cxt.cmds.cap_ls.resp)
cxt.state.cap_ls = true
end
if (str ~= nil) then
util.debug("auth", str, itte.config.debug)
-- Wait for server to respond with capabilities list before greeting
if (not cxt.state.cap_greeted) then
itte.send_command(cxt.con, cxt.cmds.user.resp)
itte.send_command(cxt.con, cxt.cmds.nick.resp)
cxt.state.cap_greeted = true
end
-- Request capabilities
if util.is_substr(str, cxt.cmds.cap_req.check) then
itte.send_command(cxt.con, cxt.cmds.cap_req.resp .. ":" ..
table.concat(cxt.cap, " "))
-- Authenticate with plain SASL
elseif (util.is_substr(str, cxt.cmds.auth_plain.check)) then
itte.send_command(cxt.con, cxt.cmds.auth_plain.resp)
elseif (util.is_substr(str, cxt.cmds.auth_pass.check)) then
local mime = require("mime")
-- Format of the string to encode: "user\0user\0password"
local sasl_pass, _ = mime.b64(cxt.auth_user .. "\0" .. cxt.auth_user ..
"\0" .. cxt.auth_pass)
itte.send_command(cxt.con, cxt.cmds.auth_pass.resp .. sasl_pass)
-- Look for auth success signal and end cap negotiation
elseif (util.is_substr(str, cxt.cmds.cap_end.check)) then
itte.send_command(cxt.con, cxt.cmds.cap_end.resp)
cxt.state.authed = true
cxt.state.cap_checked = true
if (cxt.state.cap_checked) and (not cxt.state.authed) and
(itte.config.notify_errors) then
itte.send_command(cxt.con, cxt.cmds.cap_end.resp)
itte.message(cxt, util.table_keys(cxt.admins),
itte.config.errors.sasl_auth_failed)
cxt.state.cap_checked = true
end
end
end
end
itte.docs.auth_nickserv = [[ (context_table, data_str)
Authenticate with NickServ commands.
]]
function itte.auth_nickserv(cxt, str)
if str ~= nil then
util.debug("auth", str, itte.config.debug)
if util.is_substr(str, cxt.cmds.ns_identify.check) then
itte.send_command(cxt.con, cxt.cmds.ns_identify.resp)
cxt.state.ns_checked = true
elseif util.is_substr(str, cxt.cmds.ns_identify_pass.check) then
cxt.state.authed = true
if (cxt.state.ns_checked) and (not cxt.state.authed) and
(itte.config.notify_errors) then
itte.message(cxt, util.table_keys(cxt.admins).
itte.config.errors.ns_auth_failed)
end
end
end
end
itte.docs.is_admin = [[ (context_table, message_table)
Check whether a user is an admin. Return true if the user and password are
in the admin table, or false otherwise.
]]
function itte.is_admin(cxt, msg)
-- No password provided
if table.concat(msg.code_params) == "" then
do return false end
-- Incorrect password provided
elseif msg.code_params ~= nil then
local in_admins = util.is_entry(cxt.admins, msg.sender,
msg.code_params[#msg.code_params])
if not in_admins then
do return false end
else
return true
end
end
end
itte.docs.notify_no_perms = [[ (context_table, message_table)
Respond to an attempt to use service code by a non-admin user and send a
message to the admins if `notify_errors` is enabled in the config.
]]
function itte.notify_no_perms(cxt, msg)
itte.message(cxt, { msg.reply_to }, string.gsub(itte.config.errors.no_perm,
"{{user}}", msg.sender))
if itte.config.notify_errors then
local notify_msg = string.gsub(itte.config.errors.notify_no_perm,
"{{user}}", msg.sender)
notify_msg = string.gsub(notify_msg, "{{code}}", msg.code)
itte.message(cxt, util.table_keys(cxt.admins), notify_msg)
end
end
itte.docs.traverse_channels = [[ (context_table, mode_str, channels_table)
Given a context table, task mode and optional table of IRC channels, join or
part from the channels. Task modes: join, part. If no channel table is given,
it will join or leave the channels specified in the server configuration.
]]
function itte.traverse_channels(cxt, mode, chans)
local channels = cxt.channels
if chans ~= nil then channels = chans end
if mode == "join" then
itte.send_command(cxt.con, cxt.cmds.join.resp .. table.concat(channels,
","))
elseif mode == "part" then
itte.send_command(cxt.con, cxt.cmds.part.resp .. table.concat(channels,
","))
end
end
itte.docs.parse_privmsg = [[ (context_table, data_str)
Given the context table and a PRIVMSG data string, parse the string and
return an associative table of values.
]]
function itte.parse_privmsg(cxt, str)
local code_full = string.sub(str, string.find(str, ":" .. cxt.code_prefix) +
1 + string.len(cxt.code_prefix))
local msg = {
sender = string.sub(str, string.find(str, "!") + 2,
string.find(str, "@") - 1),
recipient = string.sub(str, string.find(str, "PRIVMSG") + 8,
string.find(str, ":!") - 2),
reply_to = string.sub(str, string.find(str, "!") + 2,
string.find(str, "@") - 1),
}
if util.is_substr(code_full, " ") then
msg.code = string.sub(code_full, 0, string.find(code_full, " ") - 1)
msg.code_params = util.split_str(string.sub(code_full,
string.find(code_full, " ") + 1))
else
msg.code = code_full
msg.code_params = {}
end
-- Reply to sender by default (private message), or reply in a channel if
-- original message recipient is a channel.
if util.is_substr(msg.recipient, "#") then
msg.reply_to = msg.recipient
end
util.debug("privmsg", "{ " ..
"sender: " .. msg.sender ..
", recipient: " .. msg.recipient ..
", reply_to: " .. msg.reply_to ..
", code: " .. msg.code ..
", code_params: " .. table.concat(msg.code_params) ..
" }", itte.config.debug)
return msg
end
-- ---------------------------------------------------------------------------
-- Service code handlers
-- ---------------------------------------------------------------------------
itte.docs._h.help = [[ (context_table, message_table)
Send a help message listing available service codes.
]]
-- Send a help message listing available service codes.
function itte._h.help(cxt, msg)
local custom_h = util.table_keys(itte.handlers)
local codes = cxt.code_prefix .. table.concat(custom_h, ", " ..
cxt.code_prefix)
-- Core service codes are shown only to admins
if itte.is_admin(cxt, msg) then
local core_h = util.table_keys(itte._h)
codes = cxt.code_prefix .. table.concat(core_h, ", " .. cxt.code_prefix) ..
", " .. cxt.code_prefix .. table.concat(custom_h, ", " ..
cxt.code_prefix)
end
local help_msg = string.gsub(itte.config.messages.help, "{{codes}}", codes)
itte.message(cxt, { msg.reply_to }, help_msg)
end
itte.docs._h.join = [[ (context_table, message_table)
Join specified channels.
]]
function itte._h.join(cxt, msg)
if not itte.is_admin(cxt, msg) then
itte.notify_no_perms(cxt, msg)
do return end
end
if msg.code_params ~= {} then
itte.traverse_channels(cxt, "join", msg.code_params)
itte.message(cxt, { msg.reply_to }, itte.config.messages.join)
end
end
itte.docs._h.part = [[ (context_table, message_table)
Leave specified channels.
]]
function itte._h.part(cxt, msg)
if not itte.is_admin(cxt, msg) then
itte.notify_no_perms(cxt, msg)
do return end
end
if msg.code_params ~= {} then
itte.traverse_channels(cxt, "part", msg.code_params)
itte.message(cxt, { msg.reply_to }, itte.config.messages.part)
end
end
itte.docs._h.ping = [[ (context_table, message_table)
Send a "pong" message.
]]
function itte._h.ping(cxt, msg)
itte.message(cxt, { msg.reply_to }, itte.config.messages.ping)
end
itte.docs._h.quit = [[ (context_table, message_table)
Disconnect from the server.
]]
function itte._h.quit(cxt, msg)
if not itte.is_admin(cxt, msg) then
itte.notify_no_perms(cxt, msg)
do return end
end
itte.send_command(cxt.con, cxt.cmds.quit.resp .. itte.config.messages.quit)
end
itte.docs._h.reload = [[ (context_table, message_table)
Reload the server config.
]]
function itte._h.reload(cxt, msg)
if not itte.is_admin(cxt, msg) then
itte.notify_no_perms(cxt, msg)
do return end
end
itte.get_config()
itte.message(cxt, { msg.reply_to }, itte.config.messages.reload)
end
-- ---------------------------------------------------------------------------
-- Service code mapping and runtime
-- ---------------------------------------------------------------------------
itte.docs.listen = [[ (context_table, data_str)
Parse the socket data string and trigger a response to the server if the
string matches preset patterns.
]]
function itte.listen(cxt, str)
util.debug("listen", str, itte.config.debug)
-- Respond to server ping
if util.is_substr(str, cxt.cmds.ping.check) then
itte.send_command(cxt.con, string.gsub(str, cxt.cmds.ping.check,
cxt.cmds.ping.resp))
-- Respond to service code
elseif util.is_substr(str, cxt.cmds.privmsg.check) then
local msg = itte.parse_privmsg(cxt, str)
-- Check for the service code in the functions table before attempting to
-- call the function.
if 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
itte.handlers[msg.code](cxt, msg)
else
itte.message(cxt, { msg.reply_to }, itte.config.errors.unknown_code)
end
end
end
itte.docs.add_listener = [[ (context_table)
Monitor a socket connection and pass the data received for parsing. If the
connection is closed by the server and was the only server connection,
set `state.connected` to false to activate a client exit.
]]
function itte.add_listener(cxt)
-- Set a short timeout to be non-blocking
cxt.con:settimeout(0.5)
local data, status = cxt.con:receive()
if data ~= nil then
itte.listen(cxt, data)
elseif status == "closed" then
util.debug("conn", "Connection " .. status, itte.config.debug)
cxt.con:close()
itte.state.connections[cxt.name] = nil
-- Check if it is the last connection and trigger client exit
-- if no connections remain.
if #itte.state.connections == 0 then
util.debug("conn", "Exiting ...", itte.config.debug)
itte.state.connected = false
end
end
end
itte.docs.run = [[ ()
Run the client.
]]
function itte.run()
itte.get_config()
-- Connect to servers and get context with socket ref for each server
local contexts = {}
for instance, prop in pairs(itte.servers) do
contexts[instance] = itte.connect_server(prop)
contexts[instance].name = instance
itte.state.connected = true
itte.state.connections[instance] = true
contexts[instance].con:settimeout(1)
-- For PASS-based authentication, send PASS before greeting the server
if contexts[instance].auth_type == "pass" then
itte.send_command(contexts[instance].con,
contexts[instance].cmds.pass.resp)
contexts[instance].state.authed = true
end
-- Greet the server in all auth scenarios except SASL, which greets the
-- server during CAP negotiation.
if (contexts[instance].auth_type ~= "sasl") then
itte.send_command(contexts[instance].con,
contexts[instance].cmds.user.resp)
itte.send_command(contexts[instance].con,
contexts[instance].cmds.nick.resp)
end
local joined_chans = false
while (itte.state.connections[instance]) and (not joined_chans) do
local data, status = contexts[instance].con:receive()
if contexts[instance].auth_type == "sasl" then
itte.negotiate_cap(contexts[instance], data)
elseif contexts[instance].auth_type == "nickserv" then
itte.auth_nickserv(contexts[instance], data)
end
-- Minimum requirements for joining channels: client has authenticated if
-- an auth type is set, or has received a NOTICE [nick] message during an
-- ident lookup.
if contexts[instance].state.authed then
itte.traverse_channels(contexts[instance], "join")
joined_chans = true
elseif (data ~= nil) then
if util.is_substr(data, contexts[instance].cmds.join.check) then
itte.traverse_channels(contexts[instance], "join")
joined_chans = true
end
end
end
end
-- Add listeners
while itte.state.connected do
for instance, cxt in pairs(contexts) do
itte.add_listener(cxt)
end
end
end
return itte

@ -1,185 +0,0 @@
import argparse
import socket
import yaml
from time import sleep
from random import randint
from sys import exit
class Util:
"""Utility functions."""
def yml(self, yml_file):
"""Open a YAML file and return a dictionary of values."""
try:
fh = open(yml_file, "r")
data = yaml.safe_load(fh)
fh.close()
except TypeError:
exit("[debug][err] Cannot load YML file. Please check it exists.")
return data
def rand(self, lst):
"""Return a random item from a given list."""
return lst[randint(0, len(lst)-1)]
def cli_flags(self):
"""Parse command line flags."""
self.argp = argparse.ArgumentParser()
self.argp.add_argument("-c", "--config", help="Config file")
return self.argp.parse_args()
class IRC:
"""Methods for basic IRC communication."""
def config(self, def_conf):
"""Load runtime settings from a YAML config file, and returns a
dictionary of config values. Looks for the file in a runtime path or in
the default location."""
self.util = Util()
# Check for runtime config locatiion
flags = self.util.cli_flags()
if flags.config != "":
cfg = self.util.yml(flags.config)
else:
cfg = self.util.yml(def_conf)
self.server = (cfg["server"]["host"], cfg["server"]["port"])
self.channels = cfg["channels"]
self.bot_nick = cfg["bot_nick"]
self.req_prefix = cfg["req_prefix"]
self.admin_user = cfg["admin"]["user"]
self.admin_code = cfg["admin"]["code"]
self.debug = cfg["debug"]
return cfg
def run(self, listen_hook):
"""A routine that connects to a server, joins channels, and attaches
the request listener hook to a loop."""
self.connect(self.server, self.bot_nick)
# Wait for server to reply before joining channels
svr_greet = self.receive()
while ("001 " + self.bot_nick) not in svr_greet:
sleep(1)
svr_greet = self.receive()
self.join_channels(self.channels)
while 1:
data = self.receive()
self.keepalive(data, self.bot_nick)
self.msg = self.parse(data, self.req_prefix)
for c in self.channels:
# Pass in a context dict for handlers
listen_hook({"msg": self.msg, "listen_chan": c})
def connect(self, server, bot_nick):
"""Connect to the server and sends user/nick information."""
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect(server)
except ConnectionError as err:
exit("[debug][err] " + str(err))
self.send("USER", bot_nick + " " + bot_nick + " " + \
bot_nick + " " + bot_nick)
self.send("NICK", bot_nick)
def disconnect(self, resp_msg, quit_msg):
"""Notify the admin user and disconnect from the server."""
self.send("PRIVMSG", resp_msg, recvr=self.admin_user)
self.send("QUIT", ":" + quit_msg)
# Currently only one server per app instance is supported, so
# disconnect also exits the app
exit("Shutting down ...")
def keepalive(self, line, bot_nick):
"""Stay connected to a server by responding to server pings."""
if line.split(" ")[0] == "PING":
resp = line.replace("PING", "PONG", 1)
if self.debug:
print("[debug][send] " + resp)
self.sock.sendall(bytes(resp + "\r\n", "utf-8"))
# Fallback to ensure process exits if timeout occurs
elif (bot_nick in line.split(" ")[0]) and \
("QUIT :Ping timeout:" in line):
exit("[debug][err] Ping timeout, exited.")
def join_channels(self, channels):
"""Join channels given a list of channel names."""
for c in channels:
if c.strip() != "" or c.strip() != "#":
self.send("JOIN", c)
def send(self, command, text, *args, **kwargs):
"""Send messages given the IRC command and text. Optionally specify a
message recipient with `recvr=user`."""
recvr = kwargs.get("recvr", "")
if recvr != "":
recvr += " :"
if self.debug:
print("[debug][send] " + command + " " + recvr + text)
bs = bytes(command + " " + recvr + text + "\r\n", "utf-8")
try:
self.sock.sendall(bs)
except BrokenPipeError as err:
if self.debug:
print("[debug] " + str(err) + " at `" + \
bs.decode("utf-8").strip() + "`")
pass
def receive(self):
"""Get messages from the connected socket."""
data = self.sock.recv(4096).decode("utf-8").strip("\r\n")
if self.debug:
print("[debug][recv] " + data)
return data
def parse(self, line, req_prefix):
"""Using received data from a socket, extract the request, the nick and
username of the requester, the channel where the request originated and
return a dictionary of values."""
data = {"req": "", "req_chan": "", "nick": "", "user": ""}
if (":" + req_prefix) in line:
data["req"] = line.split("PRIVMSG", 1)[1].split(":" + \
req_prefix, 1)[1].strip()
data["req_chan"] = line.split("PRIVMSG ", \
1)[1].split(" :", 1)[0]
data["nick"] = line.split("!~", 1)[0][1:]
data["user"] = line.split("!~", 2)[0][0:].split("@", 1)[0]
return data
def listen(self, context, trigger, handler, *args, **kwargs):
"""Listen for a trigger and call the handler. It takes a context
dictionary (to determine the channel and recipient), trigger string and
corresponding handler function. Optional flags (chan, query, admin) can
be used to specify whether a trigger is active in channels or by
/msg. e.g. `chan=False, query=True` to make a trigger query-only. By
default, it will listen in both channels and private messages."""
in_chan = kwargs.get("chan", True)
in_query = kwargs.get("query", True)
in_admin = kwargs.get("admin", False)
# Admin requests are query/pm only
if in_admin:
in_chan = False
msg = context["msg"]
channel = context["listen_chan"]
# Responses are sent via pm to the user by default, while requests made
# in a channel are sent to the channel. While it's possible to override
# the recvr, it usually easier to enable a trigger in the same location
# where the response will be sent.
context["recvr"] = msg["user"]
if msg["req"] == trigger:
# Respond only in the channel where the request was made
if in_chan and channel == msg["req_chan"]:
context["recvr"] = msg["req_chan"]
handler(context)
# Respond to query/pm
elif in_query and msg["req_chan"] == self.bot_nick:
handler(context)
# Respond only to the admin user
elif in_admin and msg["user"].lower() == \
self.admin_user.lower() and self.admin_code in msg["req"]:
handler(context)
def reply(self, cxt, text):
"""Alias of send() with PRIVMSG command preset."""
self.send("PRIVMSG", text, recvr=cxt["recvr"])

<
@ -0,0 +1,252 @@
-- ---------------------------------------------------------------------------
-- Itte Util
-- ---------------------------------------------------------------------------
--[[
-- A collection of helper functions used by Itte.
--]]
local itteutil = {}
itteutil.docs = {}
itteutil.docs.get_docs = [===[ (docstring_table [, func_str [, printd_bool]])
Given a table of docstrings and a function name, return the description for
the function name. If `name` is unspecified, return descriptions for all
functions. If `printd` is unspecified, print to stdout, or if set to false,
return results as a string.
]===]
function itteutil.get_docs(docstr, name, printd)
local docs = ""
if name ~= nil then
-- 2nd-level nested objects, e.g. x.y.func()
if itteutil.is_substr(name, "%.") then
local sep, _ = string.find(name, "%.")
local dk = name:sub(1, sep - 1)
local nk = name:sub(sep + 1)
docs = "\n" .. name .. " " .. string.gsub(docstr[dk][nk]:sub(3),
" ", "") .. "\n"
else
docs = "\n" .. name .. " " .. string.gsub(docstr[name]:sub(3), " ", "")
.. "\n"
end
else
-- Sort on-site since associative arrays have no fixed order
local dk = itteutil.table_keys(docstr)
docs = "\n"
for c = 1, #dk do
-- 2nd-level nested objects, e.g. x.y.func()
if type(docstr[dk[c]]) == "table" then
local n_dk = itteutil.table_keys(docstr[dk[c]])
for nc = 1, #n_dk do
docs = docs .. dk[c] .. "." .. n_dk[nc] .. " " ..
string.gsub(docstr[dk[c]][n_dk[nc]]:sub(3), " ", "") .. "\n"
end
else
docs = docs .. dk[c] .. " " .. string.gsub(docstr[dk[c]]:sub(3),
" ", "") .. "\n"