a nice message here

Signed-off-by: Slendi <slendi@socopon.com>
This commit is contained in:
2025-09-20 01:37:32 +03:00
parent 4b129cdaa0
commit 636d71a15f

769
main.lua
View File

@@ -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