515 lines
16 KiB
Lua
515 lines
16 KiB
Lua
|
|
-- Constants
|
|
|
|
math.randomseed(os.time())
|
|
|
|
local CONFIG = require 'config'
|
|
|
|
if CONFIG.LUA_EXTRA_PATH then package.path = package.path .. CONFIG.LUA_EXTRA_PATH end
|
|
if CONFIG.LUA_EXTRA_CPATH then package.cpath = package.cpath .. CONFIG.LUA_EXTRA_CPATH end
|
|
|
|
local FARVEL_INTERVAL = 90
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Meme utils
|
|
|
|
local imlib = require 'imlib2'
|
|
|
|
for _, path in ipairs(CONFIG.IMGGEN_PATH_FONTS) do
|
|
imlib.font.add_path(path)
|
|
end
|
|
|
|
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)
|
|
assert(type(topic) == 'string')
|
|
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(symbol) 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, CONFIG.IMGGEN_PATH_OUTPUT..'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 copy_remotely (origin_server, origin_path, target_server, target_path)
|
|
local origin = origin_server and origin_server ~= 'localhost' and origin_server..':'..origin_path or origin_path
|
|
local target = target_server and target_server ~= 'localhost' and target_server..':'..target_path or target_path
|
|
os.execute('scp '..origin..' '..target..' > /dev/null')
|
|
end
|
|
|
|
local function save_to_cloud (img)
|
|
assert(img)
|
|
--
|
|
local MEME_OUTPUT = CONFIG.IMGGEN_PATH_OUTPUT..'meme.png'
|
|
img:save (MEME_OUTPUT)
|
|
img:free()
|
|
-- Upload to dcav
|
|
local img_name = 'otmemes_'..os.time()
|
|
copy_remotely('localhost', MEME_OUTPUT, CONFIG.STORAGE_SERVER, CONFIG.STORAGE_SERVER_PATH..img_name..'.png')
|
|
return CONFIG.STORAGE_DIR_URL..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(CONFIG.IMGGEN_PATH_BRAINS..'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 do
|
|
local ORD_MED_ATTITYDE = {
|
|
bedre = 'positiv',
|
|
dårligere = 'negativ'
|
|
hurtigere = 'positiv'
|
|
langsommere = 'negativt'
|
|
robust = 'positivt'
|
|
}
|
|
local COMP_SENTENCES = {}
|
|
for ord, attityde in pairs(ORD_MED_ATTITYDE) do
|
|
local first, second = '{A}', '{B}'
|
|
if attityde == 'negativ' then first, second = second, first end
|
|
for _, fmt in ipairs { '%s er %s end %s', '%s er mere %s end %s', '%s er meget %s end %s', '%s %s end %s' } do
|
|
table.insert(COMP_SENTENCES, fmt:format(first, ord, second))
|
|
end
|
|
end
|
|
end
|
|
|
|
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!',
|
|
'Do androids dream of electric sheep?',
|
|
}
|
|
|
|
bot:hook('OnJoin', function(user, channel)
|
|
if user.nick == BOT_NICK then
|
|
-- On self join
|
|
io.write(string.format(' !! Joined %s\n', channel))
|
|
if #imlib.font.list_fonts() == 0 then
|
|
common_error(channel, 'Hjælp! Min computer har ingen fonts! Hvorfor er fonts så svære på linux? Jeg har kigget i: "'..table.concat(imlib.font.list_paths(), '", "')..'"')
|
|
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 FORRIGE_FARVEL = 0
|
|
|
|
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
|
|
if FORRIGE_FARVEL + FARVEL_INTERVAL < os.time() then
|
|
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)
|
|
FORRIGE_FARVEL = os.time()
|
|
return 'SES'
|
|
end
|
|
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
|
|
|
|
local function init_memebot ()
|
|
bot:connect {
|
|
host = CONFIG.IRC_SERVER,
|
|
port = CONFIG.IRC_PORT,
|
|
secure = CONFIG.IRC_SECURE,
|
|
}
|
|
|
|
io.write ('Memebot has started up as "'..BOT_NICK..'"\n')
|
|
|
|
assert(CONFIG.IRC_CHANNELS == nil or type(CONFIG.IRC_CHANNELS) == 'table')
|
|
for _, channel in ipairs(CONFIG.IRC_CHANNELS or {}) do
|
|
bot:join(channel)
|
|
end
|
|
|
|
|
|
while true do
|
|
bot:think()
|
|
sleep(0.5)
|
|
end
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
if ... then
|
|
error 'Cannot be loaded like library'
|
|
else
|
|
init_memebot()
|
|
end
|
|
|