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