parent
8d262f2fb1
commit
9005c0fba5
|
@ -1,8 +1,3 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.swp
|
||||
|
||||
nohup.out
|
||||
*.config.yml
|
||||
*.log
|
||||
*.servers.lua
|
||||
examples/itte*.lua
|
||||
!sample.servers.lua
|
||||
|
|
38
README.md
38
README.md
|
@ -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
|
||||
|
|
|
@ -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
|
185
itte.py
185
itte.py
|
@ -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"
|
||||
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
|
191
ramenkan.py
191
ramenkan.py
|
@ -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()
|
|
@ -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: ""
|
|
@ -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
|
|
@ -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
|
|
@ -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! (^v^)"
|
||||
error: "Hyuuuuu! Sorry, I can't seem to toot right now. Ask me again later."
|
|
@ -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
|
Loading…
Reference in New Issue