commit b176092c6747e303de64da8ac96a384a810fa41f Author: Jon Michael Aanes Date: Thu Jun 7 20:43:52 2018 +0200 Initial commit of memebot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24b8190 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +# Output files +/images/output + diff --git a/README.md b/README.md new file mode 100644 index 0000000..389cf51 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ + +# Memebot + +This is a dumb IRC bot, made in Lua. + +Requires: + +- [LuaIRC](https://github.com/JakobOvrum/LuaIRC) +- **LuaSocket** through **LuaRocks**. +- **LuaSec** through **LuaRocks**. +- **imlib2** through **LuaRocks**. +- **luajson** through **LuaRocks**. + diff --git a/images/brains/brain_1.png b/images/brains/brain_1.png new file mode 100644 index 0000000..e8c31fc Binary files /dev/null and b/images/brains/brain_1.png differ diff --git a/images/brains/brain_2.png b/images/brains/brain_2.png new file mode 100644 index 0000000..26685d0 Binary files /dev/null and b/images/brains/brain_2.png differ diff --git a/images/brains/brain_3.png b/images/brains/brain_3.png new file mode 100644 index 0000000..fd96ac3 Binary files /dev/null and b/images/brains/brain_3.png differ diff --git a/images/brains/brain_4.png b/images/brains/brain_4.png new file mode 100644 index 0000000..24cb8f0 Binary files /dev/null and b/images/brains/brain_4.png differ diff --git a/images/brains/brain_5.png b/images/brains/brain_5.png new file mode 100644 index 0000000..86ddbb4 Binary files /dev/null and b/images/brains/brain_5.png differ diff --git a/images/brains/brain_6.png b/images/brains/brain_6.png new file mode 100644 index 0000000..585d8fd Binary files /dev/null and b/images/brains/brain_6.png differ diff --git a/images/distracted_boyfriend.jpg b/images/distracted_boyfriend.jpg new file mode 100644 index 0000000..c7dd474 Binary files /dev/null and b/images/distracted_boyfriend.jpg differ diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..08bf810 --- /dev/null +++ b/main.lua @@ -0,0 +1,468 @@ + +-- Constants + +math.randomseed(os.time()) + +local BRAIN_PATH = './images/brains/' +local OUTPUT_PATH = './images/output/' +local MEME_OUTPUT = OUTPUT_PATH..'meme.png' +local SERVER_DIR = '/var/shots/b/' +local IMAGE_URL_DIR = 'https://dcav.pw/b' + +-------------------------------------------------------------------------------- +-- Meme utils + +local imlib = require 'imlib2' + imlib.font.add_path '/usr/share/fonts/TTF' + +local function flatten_onto(target_img, other_img, x0, y0) + assert(x0 + other_img:get_width() <= target_img:get_width()) + assert(y0 + other_img:get_height() <= target_img:get_height()) + + for x = 0, other_img:get_width() do + for y = 0, other_img:get_height() do + target_img:draw_pixel(x + x0, y + y0, other_img:get_pixel(x, y)) + end + end + -- + return +end + +require 'socket' +local https = require 'ssl.https' + +local json = require 'json' + +local function search_clearbit_for_logo (topic) + if not (type(topic) == 'string' and topic == topic:lower() and #topic > 0) then + return nil, 'Bad topic: '..tostring(topic) + elseif topic:match 'æ' or topic:match 'ø' or topic:match 'å' then + return nil, 'Splashbase does not like æøå: '..tostring(topic) + end + -- + for _, domain in ipairs { 'org', 'com', 'net', 'dk' } do + local search_url = ('https://logo-core.clearbit.com/%s.%s'):format(topic, domain) + local _, code, headers, status = https.request { url = search_url, method = 'HEAD' } + if code == 200 then return search_url end + end +end + +local function search_splashbase_for_image_topic (topic) + if not (type(topic) == 'string' and topic == topic:lower() and #topic > 0) then + return nil, 'Bad topic: '..tostring(topic) + elseif topic:match 'æ' or topic:match 'ø' or topic:match 'å' then + return nil, 'Splashbase does not like æøå: '..tostring(topic) + end + + local search_url = string.format('http://www.splashbase.co/api/v1/images/search?query=%s', topic:gsub('%s', '%%20')) + local body, code, headers, status = https.request(search_url) + if not body then error(code) end + local data = json.decode(body) + + if not data then return nil, 'JSON could not decode data for '..topic end + if #data.images <= 0 then return nil, 'Query returned no data for '..topic end + + local img_url = data.images[math.random(#data.images)].url + assert(type(img_url) == 'string') + return img_url +end + +local function download_file (url, filename) + -- retrieve the content of a URL + local body, code = https.request(url) + if not body then error(code) end + + -- save the content to a file + local f = assert(io.open(filename, 'wb')) -- open in "binary" mode + f:write(body) + f:close() +end + +local function clean_text (text) + return text:gsub('%s+', ' '):match('^%s*(.-)%s*$') +end + +-------------------------------------------------------------------------------- +-- Meme creations + +local function generate_bait_link() + return 'https://dcav.pw/jbait' +end + +require 'irc.init' + +local sleep = require 'socket'.sleep + +--require 'errors' 'memebot' . enable_strict_globals() + +local function determine_required_font_size (font_name, text, width) + local size_min, size_max = 1, 50 + while true do + local size_guess = math.floor((size_max + size_min)/2) + assert(size_min <= size_guess and size_guess <= size_max) + local font = assert(imlib.font.load(font_name..'/'..size_guess)) + if size_guess == size_min then return font end + local text_w = font:get_size(text) + -- + if text_w <= width then size_min = size_guess + else size_max = size_guess + end + end + return nil +end + +local COLOR_WHITE = imlib.color.new(255, 255, 255) +local COLOR_BLACK = imlib.color.new( 0, 0, 0) + +local function draw_centered_text_in_box (font_name, draw_onto, text, x0, y0, width, height, bg_color) + assert(type(font_name) == 'string') + font = determine_required_font_size(font_name, text, width) + -- + local text_w, text_h = font:get_size(text) + local x, y = x0 + (width - text_w) / 2, y0 + (height - text_h) / 2 + -- + if bg_color then draw_onto:fill_rectangle(x0, y0, width, height, bg_color) end + draw_onto:draw_text(font, text, x, y, COLOR_BLACK) +end + +local function choose_random_font () + local fonts = imlib.font.list_fonts() + assert(#fonts > 0, 'Could not find any fonts') + return fonts[math.random(#fonts)] +end + +local function load_random_font () + local font_name = choose_random_font() + local font, errmsg = imlib.font.load(font_name..'/30') + assert(font, errmsg) + return font_name +end + +local CHANCE_OF_GUARENTEED_IMAGE_SEARCH = 0.05 +local IMAGE_CHANCE = 0.5 +local SCANDI_SYMBOLS = { 'æ', 'Æ', 'ø', 'Ø', 'å', 'Å' } + + +local function should_look_for_images (topic) + if math.random() < CHANCE_OF_GUARENTEED_IMAGE_SEARCH then return true end + -- + if #topic < 2 then return false end + -- + for _, symbol in ipairs(SCANDI_SYMBOLS) do + if topic:match(SCANDI_SYMBOLS) then return false end + end + -- + return math.random() < IMAGE_CHANCE +end + +local function fill_in_topics_information (topics) + assert(type(topics) == 'table') + -- + local new_topics = {} + for i, topic in ipairs(topics) do + assert(type(topic) == 'string') + local topic_l = topic:lower() + + local url, msg + if should_look_for_images(topic_l) then + if not url then url, msg = search_clearbit_for_logo (topic_l) end + if not url then url, msg = search_splashbase_for_image_topic(topic_l) end + end + + if url then new_topics[i] = { topic = topic, type = 'image', url = url } + else new_topics[i] = { topic = topic, type = 'text', text = topic } + end + end + return new_topics +end + +local function paste_topic_onto_image (target, topic, x, y, w, h, bg_color, font_name) + assert(target) + assert(type(topic) == 'table') + assert(type(font_name) == 'string') + -- Download and paste found image + if topic.type == 'image' then + local url, filename = topic.url, OUTPUT_PATH..'topic_'..topic.topic..'.png' + download_file(url, filename) + local found_img = imlib.image.load(filename) + found_img:crop_and_scale(0, 0, found_img:get_width(), found_img:get_height(), w, h) + flatten_onto (target, found_img, x, y) + found_img:free() + --os.remove(filename) + elseif topic.type == 'text' then + local text = topic.text + draw_centered_text_in_box(font_name, target, text, x, y, w, h, bg_color) + else + assert(false, topic.type) + end +end + +local function save_to_cloud (img) + assert(img) + -- + img:save (MEME_OUTPUT) + img:free() + -- Upload to dcav + local img_name = 'otmemes_'..os.time() + os.execute('scp '..MEME_OUTPUT..' guava:'..SERVER_DIR..img_name..'.png > /dev/null') + return IMAGE_URL_DIR..img_name +end + +local function generate_distracted_boyfriend (topics) + assert(type(topics) == 'table' and #topics == 2) + + local font_name = choose_random_font() + + local base_img = imlib.image.load './images/distracted_boyfriend.jpg' + + local HEAD_W, HEAD_H = 150, 150 + + local x1, y1 = 640 + math.random(-20, 20), 50 + math.random(-20, 20) + local x2, y2 = 180 + math.random(-20, 20), 50 + math.random(-20, 20) + paste_topic_onto_image(base_img, topics[1], x1, y1, HEAD_W, HEAD_H, nil, font_name) + paste_topic_onto_image(base_img, topics[2], x2, y2, HEAD_W, HEAD_H, nil, font_name) + + -- + return save_to_cloud(base_img) +end + +local function generate_brain_explosion_image (topics) + assert(type(topics) == 'table' and 1 <= #topics and #topics <= 6) + -- + local ROW_HEIGHT = 150 + local ROW_WIDTH = 200 + local base_img = imlib.image.new(ROW_WIDTH * 2, ROW_HEIGHT * #topics) + local font_name = choose_random_font() + -- + for i, topic_info in ipairs(topics) do + paste_topic_onto_image(base_img, topic_info, 0, (i-1) * ROW_HEIGHT, ROW_WIDTH, ROW_HEIGHT, COLOR_WHITE, font_name) + + local brain_img = imlib.image.load(BRAIN_PATH..'brain_'..i..'.png') + brain_img:crop_and_scale(0, 0, brain_img:get_width(), brain_img:get_height(), ROW_WIDTH, ROW_HEIGHT) + flatten_onto (base_img, brain_img, ROW_WIDTH, (i-1) * ROW_HEIGHT) + brain_img:free() + end + + return save_to_cloud(base_img) +end + +-------------------------------------------------------------------------------- + +---- + +local BOT_NICK = 'ro-bot' + +local bot = irc.new { nick = BOT_NICK } + +local FARVEL = { + ['[Ff]arvel'] = {'ses', 'farvel'}, + ['[Gg]od%s*nat'] = {'ses', 'god nat', 'sov godt'}, + ['[Jg]eg%s+smutter'] = {'ses', 'smut godt'}, + ['[Ss]es'] = {'ses selv', 'farvel'}, +} + +local LAST_ACTIVE = os.time() +local ACTIVE_PERIOD = 10 + +local function human_delay () + local delay = { 0.5, 0.5 } + -- Not active + if LAST_ACTIVE + ACTIVE_PERIOD < os.time() then + delay = { 3, 3 } + end + -- Sleep + sleep(math.random() * delay[2] + delay[1]) + -- Set last active + LAST_ACTIVE = os.time() +end + +local function is_comparison_message (message) + local leq, gep = message:match '<', message:match '>' + return leq and not gep and '<' or not leq and gep and '>' +end + +local function reverse (l0) + local l, j = {}, #l0 + for i = 1, #l0 do + l[i], j = l0[j], j - 1 + end + return l +end + +local COMP_SENTENCES = { + '{A} er bedre end {B}', + '{A} er meget bedre end {B}', + '{B} er dårligere end {A}', +} + +for sentence_i, sentence in ipairs(COMP_SENTENCES) do + local A_pos, B_pos = sentence:find '{A}', sentence:find '{B}' + local best_first = (A_pos < B_pos) + local pattern_sentence = sentence:gsub('{[AB]}', '(.+)'):gsub('%s+','%%s+') + COMP_SENTENCES[pattern_sentence] = { best_first = best_first } + COMP_SENTENCES[sentence_i] = nil +end + +local function get_topics_from_comparison_message(message) + local comp = is_comparison_message(message) + if comp then + local topics = {} + for topic in message:gmatch('[^'..comp..']+') do + topics[#topics+1] = clean_text(topic) + end + -- Rev + if comp == '>' then topics = reverse(topics) end + -- + return topics + end + + -- Natural language comparisons + for pattern, sentence_info in pairs(COMP_SENTENCES) do + local good, bad = message:lower():match(pattern) + if not sentence_info.best_first then good, bad = bad, good end + if good then return { bad, good } end + end + + -- + return nil +end + +local function common_error(channel, text, ...) + local errmsg = text:format(...) + bot:sendChat(channel, errmsg) + error(errmsg) +end + +local ERROR_MSG = { + 'Jeg forstod... noget af det', + '/me kigger rundt forvirret', + 'Ahvad?', + 'Kan du ikke lige gentage det, på en bedre måde?', + 'Hvad sagde du?', + 'Hvorfor ikke kigge i en bogord?', + 'Nej, bare nej.', + 'Hvad tror du jeg er? Et menneske?', + 'Jeg har ingen hjerne og må tænke.', + 'Hvad siger du?', + 'Ser jeg ud til at have otte arme? Nej, for jeg har ikke nogle arme!', +} + +bot:hook('OnJoin', function(user, channel) + if user.nick == BOT_NICK then + -- On self join + if #imlib.font.list_fonts() == 0 then + common_error(channel, 'Hjælp! Min computer har ingen fonts! Hvorfor er fonts så svære på arch?') + end + else + -- On other join + human_delay() + bot:sendChat(channel, 'Hej, '..user.nick..'') + end +end) + +local function escape_pattern (text) + return text:gsub('[+-?*]', '%%%1') +end + +local function handle_message(bot, user, channel, message) + -- Direct commands + if message:lower():match('^'..escape_pattern(BOT_NICK)..'%f[%A]') then + local msg = message:sub(#BOT_NICK+1) + if msg:match '%s*join%s+(#%a)' then + local channel = msg:match '^%s*join%s+(#%a+)%s*$' + bot:sendChat(channel, "Will do! I'll join "..tostring(channel)) + bot:join(channel) + else + bot:sendChat(channel, ERROR_MSG[math.random(#ERROR_MSG)]) + end + return 'BOT' + end + + -- Bait msg + if message:match '%f[%a]bait%f[%A]' then + human_delay() + bot:sendChat(channel, generate_bait_link()) + return 'BAIT' + end + + -- Farvel msg + for farvel_fmt, possible_answeres in pairs(FARVEL) do + if message:match('%f[%a]'..farvel_fmt..'%f[%A]') then + human_delay() + local answer = possible_answeres[math.random(#possible_answeres)] + .. (math.random() < 0.5 and ', '..user.nick or '') + .. (math.random() < 0.5 and '!' or '') + bot:sendChat(channel, answer) + return 'SES' + end + end + + -- Comparison memes + local topics = get_topics_from_comparison_message(message) + if not topics then return end + + + local topics = fill_in_topics_information(topics) + + local img_link, error_message + -- + if #topics == 2 and math.random() <= 0.5 then + img_link, error_message = generate_distracted_boyfriend(topics) + else + img_link, error_message = generate_brain_explosion_image(topics) + end + -- + if img_link then + bot:sendChat(channel, img_link) + else + bot:sendChat(channel, 'Sorry, no can do. '..tostring(error_message)..'.') + end + return 'BRAIN' +end + +bot:hook("OnChat", function(user, channel, message) + if channel == BOT_NICK then channel = user.nick end + io.write '...\r' + io.flush() + local success, status = pcall(handle_message, bot, user, channel, message) + + -- Handle error + if not success then + io.write(("[ERROR] [%s] %s: %s\n\n\t%s\n\n"):format(channel, user.nick, message, status)) + bot:sendChat(channel, ERROR_MSG[math.random(#ERROR_MSG)]) + return + end + + -- Print status + status = status and '['..status:sub(1, 8)..']' or '' + status = status..string.rep(' ', 8+2 - #status) + assert(type(status) == 'string' and #status == 10) + io.write(("%s [%s] %s: %s\n"):format(status, channel, user.nick, message)) +end) + +bot:hook('OnRaw', function (line) + if line:match '^:' then return end + if line:match '^PING' then return end + io.write((" [RAW]: %s\n"):format(line)) +end) + + +-- TODO: Invite to bogus channels + +-------------------------------------------------------------------------------- +-- Main run + +bot:connect { + host = 'irc.guava.space', + port = 6697, + secure = true, +} +bot:join '#bot-test' + +io.write ('Memebot has started up as "'..BOT_NICK..'"\n') + +while true do + bot:think() + sleep(0.5) +end +