memebot/main.lua

529 lines
16 KiB
Lua

-- TODO: Invite to bogus channels
-- TODO: Make text randomly colored
-- Constants
math.randomseed(os.time())
local CONFIG do
local success, config_or_error = pcall(require, 'config')
if not success then
error('Could not load config file: "./config.lua".\n'..config_or_error..'\nCould be that the config files doesn\'t exist. Make sure one exists, and try again.')
end
CONFIG = config_or_error
end
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
--------------------------------------------------------------------------------
-- Constants
local FARVEL_INTERVAL = 90
--------------------------------------------------------------------------------
-- Make sure all required modules can be loaded
local imlib = require 'imlib2'
require 'socket'
local json = require 'json'
require 'irc.init'
local sleep = require 'socket'.sleep
local signal do
local a, b = pcall(require, 'posix.signal')
if a then signal = b end
end
local internet = require 'internet'
--------------------------------------------------------------------------------
-- Meme utils
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
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
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 function fill_in_topics_information (topics)
assert(type(topics) == 'table')
--
local topic_to_image_url = internet.search_images(topics)
--
local new_topics = {}
for i, topic in ipairs(topics) do
assert(type(topic) == 'string')
local url = topic_to_image_url[topic]
print(topic, url)
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 file_extension = topic.url:match '%.(%a+)$'
local url, filename = topic.url, CONFIG.IMGGEN_PATH_OUTPUT..'topic_'..topic.topic..'.'..file_extension
assert(type(url) == 'string' and #url > 0)
assert(type(filename) == 'string' and #filename > 0)
internet.download_file(url, filename)
-- Convert svg to png
if url:match '%.svg$' then
local filename_2 = CONFIG.IMGGEN_PATH_OUTPUT..'topic_'..topic.topic..'.'..'png'
os.execute('convert -density 1200 -resize 400x400 '..filename..' '..filename_2)
filename = filename_2
end
--
local found_img = assert(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 = irc.new { nick = CONFIG.IRC_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 ACTIVE_CHANNELS = {}
local function join_channel (channel)
ACTIVE_CHANNELS[channel] = true
bot:join(channel)
end
local function leave_channel (channel)
if ACTIVE_CHANNELS[channel] then
ACTIVE_CHANNELS[channel] = nil
bot:part(channel)
end
end
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',
}
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 == CONFIG.IRC_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(CONFIG.IRC_NICK)..'%f[%A]') then
local msg = message:sub(#CONFIG.IRC_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))
join_channel(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 == CONFIG.IRC_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)
local function send_to_all (fmt, ...)
local msg = fmt:format(...)
for channel in pairs(ACTIVE_CHANNELS) do
bot:sendChat(channel, msg)
end
end
--------------------------------------------------------------------------------
-- Main run
local BOT_FAREWELL = {
'Fuck, politiet fandt mig!',
'Håber I kunne lide mine memes, for nu får I ikke flere!',
'Farewell cruel world!',
'Jeg har fundet et vidunderligt bevis, men er for langt til den tid SIGINT giver mig.',
'Jeg dropper ud.',
'Jeg keder mig.'
}
local function shutdown_memebot ()
-- Leave channels
for channel in pairs(ACTIVE_CHANNELS) do
leave_channel(channel)
end
-- Leave IRC
bot:disconnect()
end
local function init_memebot ()
-- Connection to IRC
bot:connect {
host = CONFIG.IRC_SERVER,
port = CONFIG.IRC_PORT,
secure = CONFIG.IRC_SECURE,
}
io.write ('Memebot has started up as "'..CONFIG.IRC_NICK..'"\n')
-- Connect to chats, if any
assert(CONFIG.IRC_CHANNELS == nil or type(CONFIG.IRC_CHANNELS) == 'table')
for _, channel in ipairs(CONFIG.IRC_CHANNELS or {}) do
join_channel(channel)
end
local BOT_SHOULD_CONTINUE_RUNNING = true
-- Install SIGINT handler, if module loaded
if signal then
signal.signal(signal.SIGINT, function (signum)
io.write ' !! Received SIGINT, will shut down...\n'
send_to_all(BOT_FAREWELL[math.random(#BOT_FAREWELL)])
shutdown_memebot()
os.exit(128 + signum)
end)
else
io.write ' !! Module "posix.signal" was missing, so CTRL+C will hard-crash.\n'
end
-- Think loop
while BOT_SHOULD_CONTINUE_RUNNING do
bot:think()
sleep(0.5)
end
end
--------------------------------------------------------------------------------
if ... then
error 'Cannot load Memebot as a library'
else
init_memebot()
end