diff --git a/main.lua b/main.lua index e69de29..493094e 100644 --- a/main.lua +++ b/main.lua @@ -0,0 +1,769 @@ +-- IRC client plugin for KOReader +-- Implements a simple IRC client using LuaSocket. +-- Provides a submenu with Server list & Username, and a basic chat view. + +local DataStorage = require("datastorage") +local Dispatcher = require("dispatcher") +local InfoMessage = require("ui/widget/infomessage") +local InputDialog = require("ui/widget/inputdialog") +local LuaSettings = require("luasettings") +local MultiInputDialog = require("ui/widget/multiinputdialog") +local ConfirmBox = require("ui/widget/confirmbox") +local UIManager = require("ui/uimanager") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local TextViewer = require("ui/widget/textviewer") +local NetworkMgr = require("ui/network/manager") +local socket = require("socket") +local socketutil = require("socketutil") +local _ = require("gettext") +local T = require("ffi/util").template + +-- Chat view based on TextViewer for scrollable text & menu buttons +local IrcChatView = TextViewer:extend{ + title = _("IRC Chat"), + text = "", + add_default_buttons = true, + monospace_font = true, + text_type = "code", + _connected = false, + _closing = false, + _receive_task = nil, + _sock = nil, + _server = nil, -- {name, host, port} + _channel = nil, -- optional starting channel + _nick = nil, + -- Multi-target buffers + _buffers = nil, -- map target => text + _ordered_targets = nil, -- ordered list of targets for UI + _current_target = nil, -- active target ("*" for server console) + _server_label = nil, +} + +function IrcChatView:init() + -- Buttons: Send, Close + self.buttons_table = { + { + { + text = _("Send"), + callback = function() + self:promptSendMessage() + end, + }, + { + text = _("Leave"), + callback = function() + self:onClose() + end, + }, + } + } + -- Buffers & title + self._buffers = {} + self._ordered_targets = {} + self._current_target = nil + self._server_label = self._server and (self._server.name or (self._server.host .. ":" .. tostring(self._server.port or 6667))) or "IRC" + self:updateTitle() + TextViewer.init(self) + -- Start connection after UI init so we can show logs + UIManager:nextTick(function() self:startConnection() end) +end + +function IrcChatView:updateTitle() + local tgt = self._current_target + if tgt and tgt ~= "*" then + self.title = T(_("%1 — %2"), self._server_label, tgt) + else + self.title = self._server_label + end +end + +function IrcChatView:ensureBuffer(target) + target = target or "*" + if not self._buffers[target] then + self._buffers[target] = "" + table.insert(self._ordered_targets, target) + end +end + +function IrcChatView:switchTarget(target) + if not target then return end + self:ensureBuffer(target) + self._current_target = target + self:updateTitle() + if self.scroll_text_w and self.scroll_text_w.text_widget then + self.scroll_text_w.text_widget:setText(self._buffers[target] or "") + self.scroll_text_w:scrollToBottom() + end +end + +function IrcChatView:appendLine(line, target) + if not line or line == "" then return end + target = target or self._current_target or "*" + self:ensureBuffer(target) + local prefix = os.date("[%H:%M]") .. " " + local buf = self._buffers[target] + buf = (buf and #buf > 0) and (buf .. "\n" .. prefix .. line) or (prefix .. line) + self._buffers[target] = buf + if target == (self._current_target or "*") then + if self.scroll_text_w and self.scroll_text_w.text_widget then + self.scroll_text_w.text_widget:setText(buf) + self.scroll_text_w:scrollToBottom() + end + end +end + +function IrcChatView:startConnection() + if self._connected or self._closing then return end + local host = self._server.host + local port = tonumber(self._server.port) or 6667 + self:appendLine(T(_("Connecting to %1:%2…"), host, tostring(port))) + + local sock, err = socket.tcp() + if not sock then + self:appendLine(T(_("Socket error: %1"), tostring(err))) + return + end + + -- Do a blocking connect with a short timeout, then switch to non-blocking. + sock:settimeout(10, 't') + local ok, cerr = sock:connect(host, port) + if not ok then + self:appendLine(T(_("Connect failed: %1"), tostring(cerr))) + pcall(function() sock:close() end) + return + end + sock:settimeout(0, 'b') + self._sock = sock + + -- Authenticate if configured, then send NICK/USER; Join will be sent after welcome (001) + self._connected = true + self._registered = false + self._pending_join = self._channel + self:appendLine(T(_("Logging in as %1…"), self._nick)) + -- PASS MUST be sent before NICK/USER if needed (e.g., ZNC) + local auth_user = self._server.auth_user + local auth_pass = self._server.auth_pass + if auth_user and auth_pass then + self:sendRaw(string.format("PASS %s:%s\r\n", auth_user, auth_pass)) + elseif auth_pass and #auth_pass > 0 then + -- plain server password + self:sendRaw(string.format("PASS %s\r\n", auth_pass)) + end + self:sendRaw(string.format("NICK %s\r\n", self._nick)) + self:sendRaw(string.format("USER %s 0 * :%s\r\n", self._nick, self._nick)) + + -- Schedule receive loop + self._receive_task = function() self:receiveLoop() end + UIManager:scheduleIn(0.2, self._receive_task) +end + +function IrcChatView:sendRaw(line) + if not self._sock then return end + -- Best effort write + pcall(function() + self._sock:send(line) + end) +end + +function IrcChatView:sendMessage(msg) + if not msg or msg == "" then return end + if msg:sub(1,1) == "/" then + -- Simple slash commands: /join, /part, /me, /msg + local parts = {} + for w in msg:gmatch("%S+") do table.insert(parts, w) end + local cmd = parts[1]:sub(2):lower() + if cmd == "join" and parts[2] then + local ch = parts[2] + self:sendRaw(string.format("JOIN %s\r\n", ch)) + self:appendLine(T(_("Joining %1"), ch), ch) + self:ensureBuffer(ch) + self:switchTarget(ch) + elseif cmd == "part" then + local ch = parts[2] or self._current_target + if ch then + self:sendRaw(string.format("PART %s\r\n", ch)) + self:appendLine(T(_("Leaving %1"), ch), ch) + end + elseif cmd == "me" and parts[2] then + if self._current_target and self._current_target ~= "*" then + local action = msg:match("/me%s+(.+)$") or "" + self:sendRaw(string.format("PRIVMSG %s :\u{0001}ACTION %s\u{0001}\r\n", self._current_target, action)) + self:appendLine(T(_("* %1 %2"), self._nick, action), self._current_target) + end + elseif cmd == "msg" and parts[2] and parts[3] then + local target = parts[2] + local text = msg:match("/msg%s+%S+%s+(.+)$") or "" + self:sendRaw(string.format("PRIVMSG %s :%s\r\n", target, text)) + self:appendLine(T(_("→ [%1] %2"), target, text), target) + else + self:appendLine(_("Unknown command.")) + end + return + end + if self._current_target and self._current_target ~= "*" then + self:sendRaw(string.format("PRIVMSG %s :%s\r\n", self._current_target, msg)) + self:appendLine(T(_("<%1> %2"), self._nick, msg), self._current_target) + else + self:appendLine(_("No channel joined. Use /join #channel")) + end +end + +function IrcChatView:promptSendMessage() + local dialog + dialog = InputDialog:new{ + title = _("Send message"), + input = "", + buttons = { + { + { + text = _("Send"), + is_default = true, + callback = function() + local txt = dialog:getInputText() + UIManager:close(dialog) + self:sendMessage(txt) + end, + }, + { + text = _("Cancel"), + callback = function() + UIManager:close(dialog) + end, + }, + } + } + } + UIManager:show(dialog) + dialog:onShowKeyboard(true) +end + +function IrcChatView:receiveLoop() + if self._closing then return end + if not self._sock then return end + -- Non-blocking receive lines; iterate until no more data + for _ = 1, 50 do -- cap per tick + local line, err, partial = self._sock:receive("*l") + if line then + self:handleLine(line) + elseif err == "timeout" then + break + elseif err == "closed" then + self:appendLine(_("Disconnected.")) + self._closing = true + pcall(function() self._sock:close() end) + self._sock = nil + break + else + if partial and #partial > 0 then + self:handleLine(partial) + end + break + end + end + if not self._closing then + UIManager:scheduleIn(0.3, self._receive_task) + end +end + +function IrcChatView:handleLine(line) + -- Handle PING/PONG and display messages + -- Raw IRC line: ":prefix COMMAND params :trailing" or "PING :token" + if line:match("^PING%s*:") then + local token = line:match("^PING%s*:(.+)$") or "" + self:sendRaw("PONG :" .. token .. "\r\n") + return + end + + local prefix, command, rest = line:match("^:([^%s]+)%s+(%S+)%s+(.+)$") + if command then + command = command:upper() + if command == "PRIVMSG" then + local target, msg = rest:match("^(%S+)%s+:(.+)$") + if target and msg then + local nick = prefix:match("^([^!]+)!") or prefix + if target:sub(1,1) == "#" then + self:appendLine(string.format("<%s> %s", nick, msg), target) + else + -- PM to us + self:appendLine(string.format("[%s -> you] %s", nick, msg), nick) + end + return + end + elseif command == "NOTICE" then + local target, msg = rest:match("^(%S+)%s+:(.+)$") + if msg then + local nick = prefix and (prefix:match("^([^!]+)!") or prefix) or "*" + self:appendLine(string.format("-%s- %s", nick, msg)) + return + end + elseif command == "JOIN" then + local nick = prefix:match("^([^!]+)!") or prefix + local ch = rest:match("^:(.+)$") or rest + if ch and ch ~= "" then + self:appendLine(string.format("* %s joined %s", nick, ch), ch) + if nick == self._nick then + self:ensureBuffer(ch) + if not self._current_target then + self:switchTarget(ch) + end + end + end + return + elseif command == "PART" then + local nick = prefix:match("^([^!]+)!") or prefix + local ch, msg = rest:match("^(%S+)%s*:?(.*)$") + if ch then + self:appendLine(string.format("* %s left %s %s", nick, ch, msg or ""), ch) + end + return + elseif command == "QUIT" then + local nick = prefix:match("^([^!]+)!") or prefix + local msg = rest:match("^:(.+)$") or "" + self:appendLine(string.format("* %s quit %s", nick, msg)) + return + elseif command == "353" then -- RPL_NAMREPLY + local nickmode, chan, names = rest:match("^(%S+)%s+=%s+(%S+)%s+:(.+)$") + if names then + self:appendLine(T(_("Users: %1"), names), chan) + end + return + elseif command == "001" then -- welcome (registered) + local msg = rest:match("^%S+%s+:(.+)$") or rest + self._registered = true + self:appendLine(msg) + if self._pending_join and #self._pending_join > 0 then + self:sendRaw(string.format("JOIN %s\r\n", self._pending_join)) + end + return + elseif command == "376" or command == "422" then + -- End of MOTD / No MOTD: safe to join if not yet joined + if not self._registered then self._registered = true end + if self._pending_join and #self._pending_join > 0 then + self:sendRaw(string.format("JOIN %s\r\n", self._pending_join)) + end + return + elseif command == "433" then -- ERR_NICKNAMEINUSE + self:appendLine(_("Nickname in use. Try changing Username in menu.")) + return + end + end + -- Fallback: show raw line trimmed + self:appendLine((line:gsub("\r", ""))) +end + +function IrcChatView:onClose() + if self._closing then return end + self._closing = true + if self._receive_task then + UIManager:unschedule(self._receive_task) + self._receive_task = nil + end + if self._sock then + pcall(function() + if self._current_target and self._current_target:sub(1,1) == "#" then + self:sendRaw(string.format("PART %s\r\n", self._current_target)) + end + self:sendRaw("QUIT :bye\r\n") + self._sock:close() + end) + self._sock = nil + end + TextViewer.onClose(self) +end + +-- Extend the hamburger menu to add a list of joined channels/targets. +function IrcChatView:onShowMenu() + local ButtonDialog = require("ui/widget/buttondialog") + local SpinWidget = require("ui/widget/spinwidget") + local buttons = {} + + -- Channels/Targets section + if self._ordered_targets and #self._ordered_targets > 0 then + for _, tgt in ipairs(self._ordered_targets) do + table.insert(buttons, { + { + text = tgt, + checked_func = function() return self._current_target == tgt end, + callback = function() + self:switchTarget(tgt) + end, + }, + }) + end + -- separator row + table.insert(buttons, { { text = "—", enabled = false, callback = function() end } }) + end + + -- Font size + table.insert(buttons, { + { + text_func = function() + return T(_("Font size: %1"), self.text_font_size) + end, + align = "left", + callback = function() + local widget = SpinWidget:new{ + title_text = _("Font size"), + value = self.text_font_size, + value_min = 12, + value_max = 30, + default_value = self.monospace_font and 16 or 20, + keep_shown_on_apply = true, + callback = function(spin) + self.text_font_size = spin.value + self:reinit() + end, + } + UIManager:show(widget) + end, + } + }) + + -- Monospace toggle + table.insert(buttons, { + { + text = _("Monospace font"), + checked_func = function() + return self.monospace_font + end, + align = "left", + callback = function() + self.monospace_font = not self.monospace_font + self:reinit() + end, + } + }) + + -- Justify toggle + table.insert(buttons, { + { + text = _("Justify"), + checked_func = function() + return self.justified + end, + align = "left", + callback = function() + self.justified = not self.justified + self:reinit() + end, + } + }) + + local dialog = ButtonDialog:new{ + shrink_unneeded_width = true, + buttons = buttons, + anchor = function() + return self.titlebar.left_button.image.dimen + end, + } + UIManager:show(dialog) +end + +-- Main plugin +local IRC = WidgetContainer:extend{ + name = "irc", + fullname = _("IRC client"), + is_doc_only = false, + settings_file = DataStorage:getSettingsDir() .. "/irc_client.lua", + settings = nil, + servers = nil, -- array of { name, host, port, channels = {"#chan"} } + username = nil, +} + +function IRC:onDispatcherRegisterActions() + Dispatcher:registerAction("irc_open", {category="none", event="OpenIRC", title=self.fullname, general=true}) +end + +function IRC:init() + self:onDispatcherRegisterActions() + self:loadSettings() + self.ui.menu:registerToMainMenu(self) +end + +function IRC:loadSettings() + if self.settings then return end + self.settings = LuaSettings:open(self.settings_file) + self.username = self.settings:readSetting("username") or os.getenv("USER") or "koreader" + self.servers = self.settings:readSetting("servers") or {} +end + +function IRC:onFlushSettings() + if not self.settings then return end + self.settings:saveSetting("username", self.username) + self.settings:saveSetting("servers", self.servers) + self.settings:flush() +end + +function IRC:addToMainMenu(menu_items) + menu_items.irc_client = { + text = self.fullname, + sorting_hint = "more_tools", + sub_item_table_func = function() + return self:getRootMenuItems() + end, + } +end + +function IRC:getRootMenuItems() + local items = { + { + text = _("Server list"), + keep_menu_open = true, + sub_item_table_func = function() + return self:getServerListItems() + end, + }, + { + text_func = function() + return T(_("Default username: %1"), self.username) + end, + callback = function() + self:promptUsername() + end, + }, + } + return items +end + +function IRC:getServerDisplay(server) + local label = server.name and #server.name > 0 and server.name or (server.host or "?") + local port = server.port and tostring(server.port) or "6667" + return string.format("%s (%s:%s)", label, server.host or "?", port) +end + +function IRC:getServerListItems() + local items = {} + for idx, server in ipairs(self.servers) do + table.insert(items, { + text = self:getServerDisplay(server), + sub_item_table = self:getServerActions(server, idx), + }) + end + table.insert(items, { separator = true }) + table.insert(items, { + text = _("Add server…"), + callback = function() + self:promptAddServer() + end, + }) + return items +end + +function IRC:getServerActions(server, index) + local actions = { + { + text = _("Connect"), + callback = function() + self:connectToServer(server) + end, + }, + { + text = _("Edit"), + callback = function() + self:promptEditServer(server, index) + end, + }, + { + text = _("Delete"), + callback = function() + UIManager:show(ConfirmBox:new{ + text = T(_("Delete %1?"), self:getServerDisplay(server)), + ok_text = _("Delete"), + ok_callback = function() + table.remove(self.servers, index) + self:onFlushSettings() + UIManager:show(InfoMessage:new{ text = _("Server removed."), timeout = 2 }) + end, + }) + end, + }, + } + return actions +end + +function IRC:promptUsername() + local dialog + dialog = InputDialog:new{ + title = _("Username"), + input = self.username or "", + buttons = { + { + { + text = _("Save"), + is_default = true, + callback = function() + self.username = dialog:getInputText() + self:onFlushSettings() + UIManager:close(dialog) + end, + }, + { + text = _("Cancel"), + callback = function() + UIManager:close(dialog) + end, + }, + } + } + } + UIManager:show(dialog) + dialog:onShowKeyboard(true) +end + +function IRC:promptAddServer() + local fields = { + { text = "", hint = _("Display name (optional)") }, + { text = self.username or "koreader", hint = _("Nick (per-server)") }, + { text = "irc.libera.chat", hint = _("Host") }, + { text = "6667", hint = _("Port") }, + { text = "#koreader", hint = _("Channels (comma-separated)") }, + { text = "", hint = _("Auth user (optional, e.g., user or user/network)") }, + { text = "", hint = _("Auth password (optional)"), text_type = "password" }, + } + local dialog + dialog = MultiInputDialog:new{ + title = _("Add server"), + fields = fields, + buttons = { + { + { + text = _("Add"), + is_default = true, + callback = function() + local name, nick, host, port_str, chans, auth_user, auth_pass = unpack(dialog:getFields()) + local port = tonumber(port_str) or 6667 + local channels = {} + if chans and #chans > 0 then + for ch in chans:gmatch("[^,%s]+") do table.insert(channels, ch) end + end + if not host or host == "" then + UIManager:show(InfoMessage:new{ text = _("Host is required."), timeout = 2 }) + return + end + table.insert(self.servers, { + name = name, + nick = (nick ~= "" and nick or nil), + host = host, + port = port, + channels = channels, + auth_user = (auth_user ~= "" and auth_user or nil), + auth_pass = (auth_pass ~= "" and auth_pass or nil), + }) + self:onFlushSettings() + UIManager:close(dialog) + UIManager:show(InfoMessage:new{ text = _("Server added."), timeout = 2 }) + end, + }, + { + text = _("Cancel"), + callback = function() UIManager:close(dialog) end, + }, + } + } + } + UIManager:show(dialog) + dialog:onShowKeyboard(true) +end + +function IRC:promptEditServer(server, index) + local fields = { + { text = server.name or "", hint = _("Display name (optional)") }, + { text = server.nick or self.username or "koreader", hint = _("Nick (per-server)") }, + { text = server.host or "", hint = _("Host") }, + { text = tostring(server.port or 6667), hint = _("Port") }, + { text = table.concat(server.channels or {}, ", "), hint = _("Channels (comma-separated)") }, + { text = server.auth_user or "", hint = _("Auth user (optional, e.g., user or user/network)") }, + { text = server.auth_pass or "", hint = _("Auth password (optional)"), text_type = "password" }, + } + local dialog + dialog = MultiInputDialog:new{ + title = _("Edit server"), + fields = fields, + buttons = { + { + { + text = _("Save"), + is_default = true, + callback = function() + local name, nick, host, port_str, chans, auth_user, auth_pass = unpack(dialog:getFields()) + local port = tonumber(port_str) or 6667 + local channels = {} + if chans and #chans > 0 then + for ch in chans:gmatch("[^,%s]+") do table.insert(channels, ch) end + end + if not host or host == "" then + UIManager:show(InfoMessage:new{ text = _("Host is required."), timeout = 2 }) + return + end + server.name = name + server.nick = (nick ~= "" and nick or nil) + server.host = host + server.port = port + server.channels = channels + server.auth_user = (auth_user ~= "" and auth_user or nil) + server.auth_pass = (auth_pass ~= "" and auth_pass or nil) + self.servers[index] = server + self:onFlushSettings() + UIManager:close(dialog) + UIManager:show(InfoMessage:new{ text = _("Server saved."), timeout = 2 }) + end, + }, + { + text = _("Cancel"), + callback = function() UIManager:close(dialog) end, + }, + } + } + } + UIManager:show(dialog) + dialog:onShowKeyboard(true) +end + +function IRC:connectToServer(server) + -- If multiple channels, ask to select one; otherwise connect to the only one or none + local function open_chat(channel) + local function do_open() + local nick = server.nick or self.username or "koreader" + local view = IrcChatView:new{ + _server = { + name = server.name, + host = server.host, + port = server.port, + auth_user = server.auth_user, + auth_pass = server.auth_pass, + }, + _channel = channel, + _nick = nick, + } + UIManager:show(view) + end + -- Ensure wifi/network is up before proceeding + if NetworkMgr:willRerunWhenConnected(function() open_chat(channel) end) then + return + end + socketutil:set_timeout(socketutil.DEFAULT_BLOCK_TIMEOUT, socketutil.DEFAULT_TOTAL_TIMEOUT) + do_open() + end + + if server.channels and #server.channels > 1 then + -- Prompt to pick a channel + local buttons = {} + for _, ch in ipairs(server.channels) do + table.insert(buttons, { { text = ch, callback = function() UIManager:close(self._chan_dlg); open_chat(ch) end } }) + end + self._chan_dlg = require("ui/widget/buttondialog"):new{ + title = _("Select channel"), + buttons = buttons, + } + UIManager:show(self._chan_dlg) + else + open_chat(server.channels and server.channels[1] or nil) + end +end + +function IRC:onOpenIRC() + -- Open root submenu via info message hint + UIManager:show(InfoMessage:new{ text = _("Use Tools → IRC client"), timeout = 3 }) +end + +return IRC