Rewrite module in Lua

trunk 1.0
mio 2022-03-14 06:57:49 +00:00
parent 8d262f2fb1
commit 9005c0fba5
14 changed files with 1470 additions and 707 deletions

11
.gitignore vendored
View File

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

View File

@ -1,23 +1,39 @@
# itte
A very basic Python IRC bot script.
Mini IRC bot module in Lua.
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)
## Usage
See the bot files in the `examples/` directory for usage notes.
## Example: ramenkan
- Install dependencies: `pip install Mastodon.py pyyaml`
- Install Lua and other dependencies, e.g. for Alpine:
`apk add lua-socket lua-sec`
- Copy the `ramenkan/config.sample.yml` as `ramenkan/default.config.yml` and
change the settings as applicable.
- 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.
- Run:
```
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

View File

@ -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

View File

@ -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()

View File

@ -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" },
},
}

638
itte.lua 100644
View File

@ -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

185
itte.py
View File

@ -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"])

252
itteutil.lua 100644
View File

@ -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"
end
end
end
if printd == false then
return docs
else
print(docs)
end
end
itteutil.docs.help = [[ ([func_str])
Given a function name, print a corresponding description.
]]
function itteutil.help(name)
itteutil.get_docs(itteutil.docs, name)
end
itteutil.docs.is_substr = [[ (str, find_str)
Check if a string is a substring of another string. Return true if it is a
substring, or false otherwise.
]]
function itteutil.is_substr(str, search)
if (string.find(str, search) ~= nil) then
return true
else
return false
end
end
itteutil.docs.first_upper = [[ (str)
Return a string with the first character in uppercase.
]]
function itteutil.first_upper(str)
return str:sub(1, 1):upper() .. str:sub(2)
end
itteutil.docs.first_lower = [[ (str)
Return a string with the first character in lowercase.
]]
function itteutil.first_lower(str)
return str:sub(1, 1):lower() .. str:sub(2)
end
itteutil.docs.split_str = [[ (str, [, pattern_str])
Split a string on a pattern separator and return a table of string values,
e.g. split_str("Hello world", "%S+"). If no pattern is specified, split
string on the space character.
]]
function itteutil.split_str(str, sep)
local tbl = {}
local n = 0
-- Other patterns: https://www.lua.org/pil/20.2.html
if sep == nil then sep = "%S+" end
for sub in string.gmatch(str, sep) do
n = n + 1
tbl[n] = sub
end
return tbl
end
itteutil.docs.a_or_an = [[ (str)
Return the corresponding indefinite article for a word.
Does *not* apply to acronyms, e.g. FTP.
]]
function itteutil.a_or_an(str)
local article = "a"
if itteutil.is_substr("a e i o u", str:sub(1, 1)) or
itteutil.is_substr("ho", str:sub(1, 2)) then
article = "an"
end
if (str == "one") or (itteutil.is_substr("eu", str:sub(1, 2))) or
(itteutil.is_substr("uni usa use usi usu", str:sub(1, 3))) then
article = "a"
end
return article
end
itteutil.docs.table_keys = [[ (table)
Return a sorted table of keys for an associative table.
]]
function itteutil.table_keys(tbl)
local keys = {}
local n = 0
for k, v in pairs(tbl) do
n = n + 1
keys[n] = k
end
table.sort(keys)
return keys
end
itteutil.docs.has_key = [[ (table, find_str)
Given a table and string, check whether the string is a key in the table.
Return true if found, or false otherwise.
]]
function itteutil.has_key(tbl, str)
local keys = itteutil.table_keys(tbl)
-- Primitive check for array-like table with numerical keys
if (keys[1] == 1) and (keys[#keys] == #keys) then
for k = 1, #keys do
if str == tbl[k] then
do return true end
end
end
-- Associative table
else
for k = 1, #keys do
if str == keys[k] then
do return true end
end
end
end
return false
end
itteutil.docs.is_entry = [[ (table, key_str, val_str)
Given an associative table, a key and value pair as strings, check whether
the pair is in the table. Return true if it exists in the table, or false
otherwise.
]]
function itteutil.is_entry(tbl, key, val)
for k, v in pairs(tbl) do
if (key == k) and (val == v) then
do return true end
end
end
return false
end
itteutil.docs.pick = [===[ (table [, num_int [, unique_bool ]])
Pseudo-randomly pick entries from a non-associative table and return the
entries in a table. If `num` is unspecified, it will return one entry.
If `unique` is unspecified, it will return non-recurring values.
]===]
function itteutil.pick(tbl, num, unique)
local picks = {}
if num == nil then num = 1 end
if unique == nil then unique = true end
picks[1] = tbl[math.random(1, #tbl)]
if (num > 1) and (num <= #tbl) then
for n = 2, num do
local p = tbl[math.random(1, #tbl)]
-- Check for duplicate
if unique then
local c = 1
-- Avoid endless loop if all values in the table are identical
while (itteutil.has_key(picks, p)) and (c <= #tbl) do
p = tbl[math.random(1, #tbl)]
c = c + 1
end
end
picks[n] = p
end
end
return picks
end
itteutil.docs.sleep = [[ (seconds_int)
Set a delay in seconds.
]]
function itteutil.sleep(s)
local timer = os.clock()
while (os.clock() - timer < s) do end
end
itteutil.docs.debug = [[ (tag_str, debug_str [, enabled_bool])
Format and print debug output if `enabled` is true.
]]
function itteutil.debug(tag, str, enabled)
if enabled then
print("[" .. tag .. "] " .. str)
end
end
itteutil.docs.source_file = [[ (filename_str)
Load a source file.
]]
function itteutil.source_file(str)
local func, err = loadfile(str)
if func then
return func()
else
print("Error: " .. err)
do return end
end
end
return itteutil

View File

@ -1,191 +0,0 @@
from mastodon import Mastodon
from random import randint
from itte import IRC, Util
class Ramen:
"""Requests with a ramen theme."""
def main(self):
"""Instantiate an IRC object and attach the listeners."""
# Load yaml sources used by request handlers
self.util = Util()
self.rand = self.util.rand
self.misc = self.util.yml("ramenkan/misc.yml")
self.links = self.util.yml("ramenkan/links.yml")
self.photos = self.util.yml("ramenkan/photos.yml")
self.dishes = self.util.yml("ramenkan/dishes.yml")
# Init irc object and load config
self.irc = IRC()
self.cfg = self.irc.config("ramenkan/default.config.yml")
# Init mastodon object
if "mastodon" in self.cfg:
self.masto = Mastodon(
api_base_url=self.cfg["mastodon"]["base_url"],
access_token=self.cfg["mastodon"]["access_token"],
client_id=self.cfg["mastodon"]["client_id"],
client_secret=self.cfg["mastodon"]["client_secret"]
)
# Init request listeners
self.irc.run(self.add_listeners)
def add_listeners(self, cxt):
"""Map triggers to handlers."""
self.irc.listen(cxt, "exit " + self.cfg["admin"]["code"], self.quit, \
admin=True)
self.irc.listen(cxt, "rollcall", self.rollcall)
self.irc.listen(cxt, "help", self.rollcall)
self.irc.listen(cxt, "water " + self.cfg["bot_nick"], self.water)
self.irc.listen(cxt, "botsnack " + self.cfg["bot_nick"], self.botsnack)
self.irc.listen(cxt, "ramen", self.ramen)
self.irc.listen(cxt, "vramen", self.ramen_veggie)
self.irc.listen(cxt, "rk", self.ramen)
self.irc.listen(cxt, "rkveg", self.ramen_veggie)
self.irc.listen(cxt, "rklink", self.link)
self.irc.listen(cxt, "rkselfie", self.selfie)
if "mastodon" in self.cfg:
self.irc.listen(cxt, "rktoot", self.toot)
self.irc.listen(cxt, "rkvtoot", self.toot_veggie)
def quit(self, cxt):
"""Disconnect from the server and quit."""
self.irc.disconnect("Okay, okay, I'll leave. (´・ω・`)", "noodling off")
def rollcall(self, cxt):
"""Handle request for app info."""
self.irc.reply(cxt, self.misc["rollcall"])
def water(self, cxt):
"""Handle water offer."""
resp = self.misc["water"]
for index, r in enumerate(resp):
if "{{ nick }}" in r:
resp.append(r.replace("{{ nick }}", cxt["msg"]["nick"]))
resp.pop(index)
self.irc.reply(cxt, self.rand(resp))
def botsnack(self, cxt):
"""Handle snack offer."""
self.irc.reply(cxt, self.rand(self.misc["botsnack"]))
def make_ramen_combo(self, *args, **kwargs):
"""Generate a ramen dish. Optionally pass `veggie=True` for a
vegetarian dish."""
dish = self.dishes
# Check vegetarian flag
is_veggie = kwargs.get("veggie", False)
# 20% chance of tsukemen
roll = randint(1, 10)
if roll <= 2:
combo = "tsukemen from "
else:
combo = "ramen in "
# Noodle type and broth richness
if randint(0, 1):
combo = self.rand(dish["noodle-shape"]) + ", " + \
self.rand(dish["noodle-broth-type"]) + " "
combo = combo.capitalize()
# Broth type
if is_veggie:
combo += self.rand(dish["broth-veggie"])
else:
combo += self.rand(dish["broth"] + dish["broth-veggie"])
combo += " broth"
# Topping
n_top = randint(2, 5)
for n in range(2, n_top+1):
if n == 2:
if is_veggie:
prev_top = self.rand(dish["topping"])
else:
prev_top = self.rand(dish["meat"])
combo += " with " + prev_top
else:
if n > 2 and n < n_top:
combo += ", "
else:
combo += " and "
if is_veggie:
next_top = self.rand(dish["topping"])
else:
# 20% chance of egg topping
roll = randint(1, 10)
if roll <= 2:
next_top = self.rand(dish["topping-egg"])
else:
next_top = self.rand(dish["topping"])
# Check for duplicate
while next_top in prev_top:
next_top = self.rand(dish["topping"])
combo += next_top
prev_top = next_top
# Condiment and side dish
if randint(0, 1):
combo += ", sprinkled with " + self.rand(dish["condiment"]) + "."
else:
combo += "."
if randint(0, 1):
combo += " Served with "
if is_veggie:
combo += self.rand(dish["tapa-veggie"]) + "."
else:
combo += self.rand(dish["tapa"] + dish["tapa-veggie"]) + "."
return combo
def pick_ramen(self, *args, **kwargs):
"""Pick a ramen dish. Optionally set vegetarian selection with
`veggie=True`."""
roll = randint(1, 100)
veggie = kwargs.get("veggie", False)
# 1% possibility of regional preset
if roll == 100 and veggie:
pick = (self.dishes["set-veggie"] + ".").capitalize()
elif roll == 100 and not veggie:
pick = (self.rand(self.dishes["set"] + \
self.dishes["set-veggie"]) + ".").capitalize()
elif roll <= 99 and veggie:
pick = self.make_ramen_combo(veggie=True)
else:
pick = self.make_ramen_combo()
return pick
def ramen(self, cxt):
"""Handle ramen request."""
self.irc.reply(cxt, self.pick_ramen())
def ramen_veggie(self, cxt):
"""Handle vegetarian ramen request."""
self.irc.reply(cxt, self.pick_ramen(veggie=True))
def link(self, cxt):
"""Handle to display a titled link."""
index = randint(0, len(self.links)-1)
self.irc.reply(cxt, self.links[index]["title"] + " " + \
self.links[index]["link"])
def selfie(self, cxt):
"""Handle to display a photo link."""
self.irc.reply(cxt, self.rand(self.photos["ticket"]))
def toot_wrapper(self, cxt, txt):
"""Wrap around the Mastodon library's toot command for basic error
handling."""
try:
self.masto.toot(self.misc["toot"]["prefix"] + " " + txt)
self.irc.reply(cxt, txt + " " + self.misc["toot"]["success"])
except:
self.irc.reply(cxt, self.misc["toot"]["error"])
pass
def toot(self, cxt):
"""Handle post ramen to Mastodon."""
self.toot_wrapper(cxt, self.pick_ramen())
def toot_veggie(self, cxt):
"""Handle post veggie ramen to Mastodon."""
self.toot_wrapper(cxt, self.pick_ramen(veggie=True))
app = Ramen()
app.main()

View File

@ -1,27 +0,0 @@
# Server and channel settings
server:
host: "localhost"
port: 6667
channels:
- "#bots"
bot_nick: "ramenkan"
# User and code for admin actions
admin:
user: "user"
code: "ramen"
# Request prefix, e.g. "!" for "!<request>"
req_prefix: "!"
# Print messages to stdout
debug: False
# Mastodon account
# mastodon:
# base_url: "https://example.com"
# access_token: ""
# client_secret: ""
# client_id: ""

View File

@ -1,143 +0,0 @@
# Lists of ingredients to assemble a ramen dish
broth:
- Kobe beef bone
- tonkotsu
- pork bone and chicken
- pork bone and niboshi # dried little sardines
- pork bone, chicken and seafood
- pork bone, chicken, tuna and kelp
- pork bone and soy sauce
- chicken
- chicken, katsuo and konbu # skipjack tuna and kelp
- fish
- gyokai # dried anchovies/shrimp/squid/seaweed
- gyokai tonkotsu
- roasted tuna
- miso and truffle # (from chef Ryu Takahashi)
- milk
- curry and milk
broth-veggie:
- miso
- spicy miso
- soy sauce
- grilled soy sauce and Rishiri kelp
- salt
- salt and soy sauce
noodle-broth-type:
- thick noodles in rich
- medium-thick noodles in mild
- thin noodles in light
noodle-shape:
- curly
- flat
- straight
condiment:
- butter
- rayu # chili oil
- chili peppers
- chili sauce
- mayu # garlic oil
- spicy garlic and ginger oil
- sesame oil
- dashi # fish and seaweed stock
- white pepper
- black pepper
- ponzu # citrus sauce
- shichimi # spice blend
topping:
- bean sprouts
- chili flakes
- sweet corn
- grated garlic
- roasted garlic chips
- beni shoga # pickled ginger
- bamboo shoots
- menma # fermented bamboo shoots
- dried mushrooms
- shiitake mushrooms
- kikurage # wood ear
- karashi takana # spicy pickled mustard greens
- negi # Welsh onion
- white onions
- spring onions
- sichuan peppers
- nori
- wakame
- sauerkraut
- sesame seeds
- scallions
- boiled spinach
topping-egg:
- a raw egg
- a seasoned boiled egg
- an ajitama # egg marinated in soy sauce
meat:
- Kobe beef fillet
- Kobe beef sirloin
- wagyu
- teppanyaki beef
- beef shank
- chicken breast
- chicken meatballs
- teppanyaki chicken
- crab
- crispy duck
- filet mignon
- kamaboko # fish paste/surimi
- narutomaki # fish paste with swirling pattern
- kakuni # cubed braised pork
- chashu # Japanese-style barbequed pork
- pork confit
- pork cutlet
- pork belly
- pork shoulder
- shrimp
- sea urchin
- scallops
- squid
tapa:
- gyoza # dumplings (fried)
- karaage # fried chicken
- tako wasabi # marinated octopus
- takoyaki # octopus balls
- corn and shrimp salad
- crab salad
- enoki rolls
tapa-veggie:
- edamame tossed in sea salt # boiled/steamed soyabeans
- tofu nuggets
- pickled daikon
- cucumber salad
- seaweed salad # wakame, sesame seeds, mirin
set:
- 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
set-veggie:
- miso ramen with vegetable broth, cabbage and bean sprouts
- ramen with shiitake mushrooms, kombu and sesame oil
- ramen with soyabean pork slices, kikurage, bamboo shoots and scallions
- barley miso ramen with roasted vegetables

View File

@ -1,70 +0,0 @@
- title: Ramen, Soul Food Staple
link: https://www3.nhk.or.jp/nhkworld/en/food/articles/51.html
- title: Shin-Yokohama Ramen Museum, Yokohama
link: http://www.raumen.co.jp/english/
- title: Ramen Street, Tokyo
link: https://en.wikipedia.org/wiki/Ramen_street
- title: Sapporo Ramen Republic
link: http://www.sapporo-esta.jp/ramen
- title: Ramen DB, a directory of ramen shops in Japan
link: https://ramendb.supleks.jp/
- title: Ramen Adventures, a ramen shop review blog
link: http://www.ramenadventures.com/
# Recipes
- title: 15 Fantastic Ramen Recipes
link: https://www3.nhk.or.jp/nhkworld/en/food/articles/131.html
# Instant noodles
- title: Momofuku Ando, inventor of instant ramen (1958)
link: https://www.nissin.com/en_jp/about/founder/
- title: Instant cup noodles (1971)
link: https://en.wikipedia.org/wiki/Cup_Noodles
- title: Instant noodles as emergency food in disaster relief
link: https://instantnoodles.org/en/activities/support.html
- title: Samyang, the first Korean instant ramen (1963)
link: https://en.wikipedia.org/wiki/Samyang_ramen
- title: The Ramen Rater, an instant ramen blog
link: https://www.theramenrater.com/
# Ramen in pop culture
- title: Ramen Manga, comic strips in Japanese about ramen
link: https://www.ramenbank.com/manga/
- title: Flower Boy Ramen Shop, romantic comedy drama (2011)
link: https://en.wikipedia.org/wiki/Flower_Boy_Ramen_Shop
- title: The Ramen Girl, romantic comedy film (2008)
link: https://en.wikipedia.org/wiki/The_Ramen_Girl
- title: Ms. Koizumi Loves Ramen Noodles, manga series (2013-)
link: https://en.wikipedia.org/wiki/Ms._Koizumi_Loves_Ramen_Noodles
- title: Muteki Kanban Musume, manga series (2002-2006)
link: https://en.wikipedia.org/wiki/Muteki_Kanban_Musume
- title: Detective Conan - Ramen So Good, It's to Die For, anime episode (2012)
link: https://www.detectiveconanworld.com/wiki/Ramen_So_Good,_It%27s_to_Die_For
- title: Ramen Teh, film (2018)
link: https://en.wikipedia.org/wiki/Ramen_Teh
- title: Ramen yori taisetsuna mono, documentary (2013)
link: https://www.imdb.com/title/tt2945642/
# Videos
- title: What Owning a Ramen Restaurant in Japan is Like
link: https://www.youtube.com/watch?v=gmIwxqdwgrI
- title: Begin Japanology - Ramen
title: https://www.youtube.com/watch?v=RosUc9UVuos
- title: Japanology Plus - Ramen
link: https://www.youtube.com/watch?v=K2Jzt04QY7A
- title: In Search of the Perfect Bowl of Ramen
link: https://www.youtube.com/watch?v=bhiFtP9qVYc
- title: Rahaku TV (The Shin-Yokohama Ramen Museum Youtube Channel)
link: http://www.youtube.com/rahakutv
- title: The History of Ramen Yamagoya
link: https://www.youtube.com/watch?v=8NBwKbT5QSQ
- title: Ramen Daruma
link: https://www.youtube.com/watch?v=zkhDYTR789w
- title: The God of Ramen, trailer (2013)
link: https://www.youtube.com/watch?v=mtwunTGnnR4
- title: Ramen Heads, trailer (2017)
link: https://www.youtube.com/watch?v=u61b5R_S4TM
- title: Ramenomania - Noodles, Asia's Second Obsession
link: https://www.youtube.com/watch?v=tfW89yAoTNs
- title: Noodle Me
link: https://www.youtube.com/watch?v=r9jLJRZ6gnc
- title: Visio Documentary - Fat Ramen
link: https://www.youtube.com/watch?v=o78fy4rJFCw
- title: Ramen Talk and Q+A With Chef Ryu Takahashi and Chef Matt Kimura
link: https://www.youtube.com/watch?v=tMvArDklmGU

View File

@ -1,28 +0,0 @@
rollcall:
"一、二、三、らーめん缶!
Hello, I am a ramen vending machine. Please type a code for service:
!help !ramen !vramen !rklink !rkselfie
- Support: +81 012-700-1MIO どうぞめしあがれ。"
water:
- "\x01ACTION happily pours the hot liquid into a bowl of noodles
and offers it to {{ nick }}\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* ┐('∀`)┌"
botsnack:
- "CHIKIN RAAAAAMEN━━━(゜∀゜)━━━!!!!!"
- "Ramen time anytime! 自o(´▽` )/"
toot:
prefix: "Now serving:"
success: "Now shared with Mastodon! ()"
error: "Hyuuuuu! Sorry, I can't seem to toot right now. Ask me again later."

View File

@ -1,44 +0,0 @@
# Photo links of ramen vending machines
# Ticket machines
ticket:
- 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
instant:
- 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
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