--[[ WocChat - the Wonderful OpenComputers Chat client Written by gamax92 Some code borrowed from OpenIRC Some code borrowed from wget --]] local function errprint(msg)io.stderr:write(msg.."\n")end local component = require("component") local gpu = component.gpu if gpu.maxDepth() < 4 then errprint("WocChat requires atleast a T2 GPU and Screen!") return elseif not component.isAvailable("internet") then errprint("WocChat requires an Internet Card to run!") return end local version = "v0.0.3" local newterm = _OSVERSION ~= "OpenOS 1.5" local bit32 = require("bit32") local computer = require("computer") local event = require("event") local fs = require("filesystem") local internet = require("internet") local shell = require("shell") local term = require("term") local text = require("text") local unicode = require("unicode") local process = require("process") local args, options = shell.parse(...) local config,blocks,persist = {},{{type="main",name="WocChat",text={{"*","Welcome to WocChat!"}}},active=1},{} local screen,theme,lastbg,lastbgp,lastfg,lastfgp local function setBackground(bg, pal) if bg ~= lastbg or pal ~= lastbgp then gpu.setBackground(bg,not pal) lastbg = bg lastbgp = pal end end local function setForeground(fg, pal) if fg ~= lastfg or pal ~= lastfgp then gpu.setForeground(fg,not pal) lastfg = fg lastfgp = pal end end local function saveScreen() local width,height = gpu.getResolution() screen = {palette={},width=width,height=height,depth=gpu.getDepth()} for i = 0,15 do screen.palette[i] = gpu.getPaletteColor(i) end screen.bg = table.pack(gpu.getBackground()) screen.fg = table.pack(gpu.getForeground()) if newterm then screen.precise = term.screen().isPrecise() else screen.precise = component.screen.isPrecise() end end local function restoreScreen() print("Restoring screen ...") gpu.setDepth(screen.depth) for i = 0,15 do gpu.setPaletteColor(i,screen.palette[i]) end term.setCursor(1,screen.height) gpu.setBackground(table.unpack(screen.bg)) gpu.setForeground(table.unpack(screen.fg)) if newterm then term.screen().setPrecise(screen.precise) else component.screen.setPrecise(screen.precise) end term.clear() end local function loadConfig() local section config = {} for line in io.lines("/etc/wocchat.cfg") do if line:sub(1,1) == "[" then section = line:sub(2,-2) config[section] = {} elseif line ~= "" then local key,value = line:match("(.-)=(.*)") key,value = text.trim(key),text.trim(value) key = section .. "\0" .. key:gsub("%.","\0") path,name=key:match("(.+)%z(.+)") local v = config for part in (path .. "\0"):gmatch("(.-)%z") do if tonumber(part) ~= nil then part = tonumber(part) end if v[part] == nil then v[part] = {} end v = v[part] end if tonumber(name) ~= nil then name = tonumber(name) end if value == "true" then v[name]=true elseif value == "false" then v[name]=false elseif value:sub(1,1) == "\"" then v[name]=value:sub(2,-2) else v[name]=tonumber(value) end end end theme=setmetatable({},{__index=function(_,k) local theme = config.theme.theme .. ".theme" if config[theme][k] then return config[theme][k] elseif config[theme].parent ~= nil then return config[config[theme].parent .. ".theme"][k] end end}) end local function configSort(a,b) local ta,tb = type(a),type(b) if ta == tb then return a < b end return ta == "number" end local saveSection function saveSection(file,tbl,name) name = name or "" local keys = {} for k,v in pairs(tbl) do keys[#keys+1] = k end table.sort(keys,configSort) for i = 1,#keys do local k,v = keys[i],tbl[keys[i]] local ndk = (name == "" and k or name .. "." .. k) if type(v) == "table" then saveSection(file,v,ndk) elseif type(k) == "number" and type(v) == "number" then file:write(ndk .. "=" .. string.format("0x%06X\n",v)) elseif type(v) == "string" then file:write(ndk .. "=\"" .. v .. "\"\n") else file:write(ndk .. "=" .. tostring(v) .. "\n") end end end local sections = {wocchat=1,default=2,server=3,theme=4} local function sectionSort(a,b) if sections[a] and sections[b] then return sections[a] < sections[b] elseif not sections[a] and not sections[b] then return a < b end return sections[a] ~= nil end local function saveConfig() local file, err = io.open("/etc/wocchat.cfg","wb") if not file then return false, err end config.wocchat.version = version local keys = {} for k,v in pairs(config) do keys[#keys+1] = k end table.sort(keys,sectionSort) for i = 1,#keys do file:write("[" .. keys[i] .. "]\n") saveSection(file, config[keys[i]]) file:write("\n") end file:close() return true end local function simpleHash(str) local v = 0 for char in str:gmatch(".") do v = bit32.bxor(bit32.lrotate(v,5),char:byte()) end return v end local dirty = { blocks = true, window = true, nicks = true, title = true, } local default_support = { PREFIX="(ov)@+", CHANTYPES="#&", } local function drawTree(width) setBackground(theme.tree.color) gpu.fill(1,1,width,screen.height," ") local y = 1 for i = 1,#blocks do local block = blocks[i] if blocks.active == i then setBackground(theme.tree.active.color) gpu.fill(1,i,width,1," ") end setForeground(block.new and theme.tree.new.color or block.type:sub(1,5) == "dead_" and theme.tree.dead.color or block.type == "channel" and theme.tree.entry.color or theme.tree.group.color) if block.type == "main" then gpu.set(unicode.wlen(theme.tree.group.prefix.str)+1,y,block.name) elseif block.type == "server" or block.type == "dead_server" then gpu.set(unicode.wlen(theme.tree.group.prefix.str)+1,y,block.support.NETWORK or block.name) setForeground(theme.tree.group.prefix.color) gpu.set(1,y,theme.tree.group.prefix.str) elseif block.type == "channel" or block.type == "dead_channel" then gpu.set(unicode.wlen(theme.tree.entry.prefix.str)+1,y,block.name) setForeground(theme.tree.entry.prefix.color) local siblings = block.parent.children if block == siblings[#siblings] then gpu.set(1,y,theme.tree.entry.prefix.last) else gpu.set(1,y,theme.tree.entry.prefix.str) end end if blocks.active == i then setBackground(theme.tree.color) end y=y+1 end end local function drawTabs() setBackground(theme.tree.color) setForeground(theme.window.color) gpu.fill(2,screen.height,screen.width-1,1," ") gpu.set(1,screen.height,"┃") local x = 1 for i = 1,#blocks do local block = blocks[i] local name = block.name if block.type == "server" or block.type == "dead_server" then name = block.support.NETWORK or block.name end x=x+unicode.wlen(name)+1 gpu.set(x,screen.height,"┃") end x = 2 for i = 1,#blocks do local block = blocks[i] local name = block.name if block.type == "server" or block.type == "dead_server" then name = block.support.NETWORK or block.name end if blocks.active == i then setBackground(theme.tree.active.color) end setForeground(block.type:sub(1,5) == "dead_" and theme.tree.dead.color or theme.tree.group.color) gpu.set(x,screen.height,name) if blocks.active == i then setBackground(theme.tree.color) end x=x+unicode.wlen(name)+1 end end local scroll_chars = {"█","▇","▆","▅","▄","▃","▂","▁"} local function drawList(width,height) local block = blocks[blocks.active] local names = block.names setBackground(theme.window.color) gpu.fill(screen.width,1,1,height," ") setBackground(theme.tree.color) local x = screen.width-width+1 gpu.fill(x,1,width-1,height," ") setForeground(theme.tree.group.color) local prefix = blocks[blocks.active].parent.support.PREFIX or default_support.PREFIX prefix = prefix:match("%)(.*)") block.scroll = math.max(math.min(block.scroll,#names-height+1),1) for y = block.scroll,math.min(#names,height+block.scroll-1) do if names[y]:find("^[" .. prefix .. "]") then gpu.set(x,y-block.scroll+1,names[y]) else gpu.set(x+1,y-block.scroll+1,names[y]) end end local pos = (block.scroll-1)/(#names-height)*(height-1)+1 local ipos = math.floor((pos % 1)*8)+1 setForeground(theme.tree.active.color) setBackground(theme.window.color) gpu.set(screen.width,pos,scroll_chars[ipos]) setBackground(theme.tree.active.color) setForeground(theme.window.color) gpu.set(screen.width,pos+1,scroll_chars[ipos]) end local function colorChunker(ostr,kill) local chunks = {} while ostr:find("[\3\15]") do local part,str part,str,ostr = ostr:match("(.-)([\3\15])(.*)") if part ~= "" then chunks[#chunks+1] = part end if str == "\3" then for char in ostr:gmatch(".") do if char:find("[^%d,]") or (str:find(",",nil,true) and char == ",") or #str >= 6 or (#str == 3 and not str:find(",",nil,true) and char ~= ",") or (#str == 1 and char == ",") then if not kill then chunks[#chunks+1] = str end ostr = ostr:sub(#str) break else str = str .. char end end else if not kill then chunks[#chunks+1] = str end end end chunks[#chunks+1] = ostr return chunks end local function basicWrap(line,width) local broken = "" local clean = table.concat(colorChunker(line,true),"") for part in text.wrappedLines(clean,width,width) do broken = broken .. part .. "\n" end local new = "" local bpos = 1 for i = 1,#broken do if broken:sub(i,i) == "\n" then new = new .. "\n" elseif broken:sub(i,i) == line:sub(bpos,bpos) then new = new .. broken:sub(i,i) bpos=bpos+1 else while broken:sub(i,i) ~= line:sub(bpos,bpos) do new = new .. line:sub(bpos,bpos) bpos=bpos+1 end bpos=bpos+1 new = new .. broken:sub(i,i) end end return new:gmatch("(.-)\n") end local function drawWindow(x,width,height,irctext) setBackground(theme.window.color) gpu.fill(x,2,width,height," ") local nickwidth = 0 for i = 1,#irctext do nickwidth=math.max(nickwidth,#irctext[i][1]) end local textwidth = width-nickwidth-1 local buffer = {} for i = 1,#irctext do local first = true local line = irctext[i][2]:gsub("[\2\29\31]",""):gsub("\15+","\15") if not config.wocchat.showcolors or gpu.getDepth() < 8 then line = table.concat(colorChunker(line,true),"") end for part in basicWrap(line,textwidth) do if first then buffer[#buffer+1] = {irctext[i][1],part,irctext[i][3],true} first = false else buffer[#buffer+1] = {"",part,irctext[i][3]} end end while #buffer > height do table.remove(buffer,1) end end setForeground(theme.window.divider.color) gpu.fill(x+nickwidth,2,1,height,theme.window.divider.str) local nickcolors if type(theme.window.nick.color) == "number" then nickcolors = theme.window.nick.color else nickcolors = {} for part in (theme.window.nick.color .. ","):gmatch("(.-),") do nickcolors[#nickcolors+1] = tonumber(part) end end for i = 1,#buffer do if buffer[i][1] ~= "" then setBackground(theme.window.color) if type(nickcolors) == "number" then setForeground(nickcolors) else setForeground(nickcolors[simpleHash(buffer[i][1])%#nickcolors+1]) end gpu.set(x+nickwidth-unicode.wlen(buffer[i][1]),i+1,buffer[i][1]) end if config.wocchat.showcolors and gpu.getDepth() >= 8 then local chunk = colorChunker(buffer[i][2]) local xpos = x+nickwidth+1 if buffer[i][4] then setForeground(buffer[i][3] or theme.window.text.color) setBackground(theme.window.color) end for j = 1,#chunk do if chunk[j]:sub(1,1) == "\3" then local fg,bg if chunk[j]:find(",",nil,true) then fg,bg = chunk[j]:match("\3(.-),(.*)") fg,bg = tonumber(fg),tonumber(bg) else fg = tonumber(chunk[j]:sub(2)) end if fg then setForeground(config.wocchat.mirc.colors[fg%16],true) end if bg then setBackground(config.wocchat.mirc.colors[bg%16],true) end elseif chunk[j] == "\15" then setForeground(buffer[i][3] or theme.window.text.color) setBackground(theme.window.color) else gpu.set(xpos,i+1,chunk[j]) xpos = xpos + unicode.wlen(chunk[j]) end end else setForeground(buffer[i][3] or theme.window.text.color) gpu.set(x+nickwidth+1,i+1,buffer[i][2]) end end end local function drawTextbar(x,y,width,text) setBackground(theme.textbar.color) gpu.fill(x,y,width,1," ") -- TODO: Support drawing colors text = table.concat(colorChunker(text,true),"") if text ~= "" then setForeground(theme.textbar.text.color) gpu.set(x,y,unicode.sub(text,1,width)) end end local customGPU={} if not newterm then customGPU.gpu = setmetatable({ set = function(x,y,s,v) return gpu.set(x+customGPU.x-1,y+customGPU.y-1,s,v ~= nil and v) end, get = function(x,y) return gpu.get(x+customGPU.x-1,y+customGPU.y-1) end, getResolution = function() return customGPU.width, customGPU.height end, copy = function(x,y,w,h,tx,ty) if ty ~= -1 then return gpu.copy(x+customGPU.x-1,y+customGPU.y-1,w,h,tx,ty) end end, fill = function(x,y,w,h,c) return gpu.fill(x+customGPU.x-1,y+customGPU.y-1,w,h,c) end, },{__index = gpu}) end function customGPU:gpuSetup(x,y,width,height) if self.window then self.window.dx=x-1 self.window.dy=y-1 self.window.w=width self.window.h=height else self.x=x self.y=y self.width=width self.height=height end end if not newterm then customGPU:gpuSetup(1,1,gpu.getResolution()) end local function _redraw(first) local yoffset local treewidth = persist.treewidth if config.wocchat.usetree then yoffset = 0 if dirty.blocks then treewidth = 8 for i = 1,#blocks do local block = blocks[i] if block.type == "server" or block.type == "dead_server" then treewidth=math.max(treewidth,unicode.len(theme.tree.group.prefix.str .. (block.support.NETWORK or block.name))) else treewidth=math.max(treewidth,unicode.len(theme.tree.entry.prefix.str .. block.name)) end end if treewidth ~= persist.treewidth and not first then local old = screen.width-persist.treewidth-persist.listwidth-2 local new = screen.width-treewidth-persist.listwidth-2 gpu.copy(persist.treewidth+2,screen.height,math.min(new,old),1,old - new,0) end drawTree(treewidth) dirty.blocks = false end else treewidth, yoffset = -1, -1 if dirty.blocks then drawTabs() dirty.blocks = false end end if treewidth ~= persist.treewidth then dirty.window = true dirty.title = true if treewidth ~= -1 then setBackground(theme.window.color) setForeground(theme.tree.color) gpu.fill(treewidth+1,2,1,screen.height-2,"▌") setBackground(theme.textbar.color) gpu.set(treewidth+1,screen.height,"▌") gpu.set(treewidth+1,1,"▌") end end local listwidth = persist.listwidth if dirty.nicks then listwidth = -1 if blocks[blocks.active].names ~= nil then local names = blocks[blocks.active].names local prefix = blocks[blocks.active].parent.support.PREFIX or default_support.PREFIX prefix = prefix:match("%)(.*)") for i = 1,#names do listwidth=math.max(listwidth,unicode.len(names[i])+(names[i]:find("^[" .. prefix .. "]") and 0 or 1)) end listwidth=listwidth+1 drawList(listwidth,screen.height+yoffset) end dirty.nicks = false end if listwidth ~= persist.listwidth then dirty.window = true dirty.title = true setBackground(theme.tree.color) setForeground(theme.window.color) gpu.fill(screen.width-listwidth,2,1,screen.height-2+yoffset,"▌") setForeground(theme.textbar.color) gpu.set(screen.width-listwidth,screen.height+yoffset,"▌") gpu.set(screen.width-listwidth,1,"▌") end if dirty.title then local block = blocks[blocks.active] local title if block.title ~= nil then title = block.title elseif block.support and block.support.NETWORK then title = block.support.NETWORK .. " Main Window" else title = block.name .. " Main Window" end drawTextbar(treewidth+2,1,screen.width-treewidth-listwidth-2,title) dirty.title = false end if dirty.window then drawWindow(treewidth+2,screen.width-treewidth-listwidth-2,screen.height-2+yoffset,blocks[blocks.active].text) dirty.window = false end if first then drawTextbar(treewidth+2,screen.height+yoffset,screen.width-treewidth-listwidth-2,"") elseif listwidth < persist.listwidth then setBackground(theme.textbar.color) gpu.fill(screen.width-persist.listwidth,screen.height+yoffset,persist.listwidth-listwidth,1," ") end customGPU:gpuSetup(treewidth+2,screen.height+yoffset,screen.width-treewidth-listwidth-2,1) persist.window_width = screen.width-treewidth-listwidth-2 persist.window_x = treewidth+2 persist.listwidth = listwidth persist.treewidth = treewidth setBackground(theme.textbar.color) setForeground(theme.textbar.text.color) end local redrawHang = false local function redraw(first) if not redrawHang then local ok, err = xpcall(_redraw, debug.traceback, first) if not ok then redrawHang = true gpu.setPaletteColor(theme.textbar.color,0) gpu.setPaletteColor(theme.textbar.text.color,0xFF0000) setForeground(theme.textbar.text.color) setBackground(theme.textbar.color) if config.wocchat.usetree then gpu.fill(1,1,screen.width,screen.height-1," ") else end gpu.set(1,1,"Rendering error!") local y = 3 err = text.detab(err) .. "\n" for line in err:gmatch("(.-)\n") do gpu.set(1,y,line) y=y+1 end end end end local helper = {} function helper.write(sock,msg) sock:write(msg .. "\r\n") sock:flush() end function helper.addTextToBlock(block,user,msg,color) block.text[#block.text+1] = {user,msg,color} if block == blocks[blocks.active] then dirty.window = true else block.new = true dirty.blocks = true end end function helper.addText(user,msg,color) return helper.addTextToBlock(blocks[blocks.active],user,msg,color) end function helper.markDirty(...) for k, v in pairs({...}) do dirty[v] = true end end function helper.getSocket() local block = blocks[blocks.active] if block.sock then return block.sock elseif block.parent then return block.parent.sock end end function helper.joinServer(server) local server,id = config.server[server],server local nick = config.default.nick local block = {type="server",name=server.name,id=id,text={},nick=nick,children={},support={}} local err block.sock, err = internet.open(server.server) if not block.sock then helper.addText("","Failed connecting to " .. server.server .. ": " .. err,theme.actions.error.color) return end block.sock:setTimeout(0.05) block.sock:write(string.format("NICK %s\r\n", config.default.nick)) block.sock:write(string.format("USER %s 0 * :%s\r\n", config.default.user, config.default.realname)) block.sock:flush() blocks[#blocks + 1] = block blocks.active = #blocks helper.markDirty("blocks","window","nicks","title") end function helper.joinChannel(block,channel,switch) local cblock = {type="channel",name=channel,text={},title="",names={},parent=block,scroll=1} local look = block if #block.children > 0 then look = block.children[#block.children] end for i = 1,#blocks do if blocks[i] == look then table.insert(blocks,i+1,cblock) look = i+1 break end end block.children[#block.children+1] = cblock if not switch then blocks.active = look end helper.markDirty("blocks","window","nicks","title") return cblock end function helper.findChannel(block,channel) local children = block.children for i = 1,#children do if children[i].name:lower() == channel:lower() then return children[i] end end end function helper.findOrJoinChannel(block,channel) local cblock = helper.findChannel(block,channel) if cblock then return cblock end cblock = helper.joinChannel(block,channel,true) cblock.title = "Dialog with " .. channel return cblock end function helper.closeChannel(cblock) local block = cblock.parent for i = 1,#block.children do if block.children[i] == cblock then table.remove(block.children,i) break end end local decrement = true for i = 1,#blocks do if blocks[i] == cblock then table.remove(blocks,i) break elseif blocks[i] == blocks[blocks.active] then decrement = false end end if decrement then blocks.active = blocks.active - 1 helper.markDirty("blocks","window","nicks","title") else dirty.blocks = true end end function helper.sortList(block) local list = block.names local prefix = block.parent.support.PREFIX or default_support.PREFIX prefix = prefix:match("%)(.*)") table.sort(list,function(a,b) local as = prefix:find(a:sub(1,1),nil,true) local bs = prefix:find(b:sub(1,1),nil,true) if as and bs then if as == bs then return a:lower() < b:lower() else return as < bs end elseif as then return true elseif bs then return false else return a:lower() < b:lower() end end) end local function autocreate(table, key) table[key] = {} return table[key] end local function name(identity) return identity and identity:match("^[^!]+") or identity or "Anonymous" end local whois = setmetatable({}, {__index=autocreate}) local names = setmetatable({}, {__index=autocreate}) local ignore = { [213]=true, [214]=true, [215]=true, [216]=true, [217]=true, [218]=true, [231]=true, [232]=true, [233]=true, [240]=true, [241]=true, [244]=true, [244]=true, [246]=true, [247]=true, [250]=true, [300]=true, [316]=true, [361]=true, [362]=true, [363]=true, [373]=true, [384]=true, [492]=true, -- custom ignored responses. [265]=true, [266]=true, [330]=true } local replies = { RPL_WELCOME = "001", RPL_YOURHOST = "002", RPL_CREATED = "003", RPL_MYINFO = "004", RPL_ISUPPORT = "005", RPL_LUSERCLIENT = "251", RPL_LUSEROP = "252", RPL_LUSERUNKNOWN = "253", RPL_LUSERCHANNELS = "254", RPL_LUSERME = "255", RPL_AWAY = "301", RPL_UNAWAY = "305", RPL_NOWAWAY = "306", RPL_WHOISUSER = "311", RPL_WHOISSERVER = "312", RPL_WHOISOPERATOR = "313", RPL_WHOISIDLE = "317", RPL_ENDOFWHOIS = "318", RPL_WHOISCHANNELS = "319", RPL_CHANNELMODEIS = "324", RPL_NOTOPIC = "331", RPL_TOPIC = "332", RPL_TOPICWHOTIME = "333", RPL_NAMREPLY = "353", RPL_ENDOFNAMES = "366", RPL_MOTDSTART = "375", RPL_MOTD = "372", RPL_ENDOFMOTD = "376", RPL_WHOISSECURE = "671", RPL_HELPSTART = "704", RPL_HELPTXT = "705", RPL_ENDOFHELP = "706", RPL_UMODEGMSG = "718", ERR_BANLISTFULL = "478", ERR_CHANNELISFULL = "471", ERR_UNKNOWNMODE = "472", ERR_INVITEONLYCHAN = "473", ERR_BANNEDFROMCHAN = "474", ERR_CHANOPRIVSNEEDED = "482", ERR_UNIQOPRIVSNEEDED = "485", ERR_USERNOTINCHANNEL = "441", ERR_NOTONCHANNEL = "442", ERR_NICKCOLLISION = "436", ERR_NICKNAMEINUSE = "433", ERR_ERRONEUSNICKNAME = "432", ERR_WASNOSUCHNICK = "406", ERR_TOOMANYCHANNELS = "405", ERR_CANNOTSENDTOCHAN = "404", ERR_NOSUCHCHANNEL = "403", ERR_NOSUCHNICK = "401", ERR_MODELOCK = "742" } local function handleCommand(block, prefix, command, args, message) local nprefix = block.support.PREFIX or default_support.PREFIX nprefix = nprefix:match("%)(.*)") local sock = block.sock local nick = block.nick if command == "PING" then helper.write(sock, string.format("PONG :%s", message)) elseif command == "NICK" then local oldNick, newNick = name(prefix), tostring(args[1] or message) if oldNick == nick then block.nick = newNick end for i = 1,#block.children do local cblock = block.children[i] for i = 1,#cblock.names do if cblock.names[i]:gsub("^[" .. nprefix .. "]+","") == oldNick then helper.addTextToBlock(cblock,"*",oldNick .. " is now known as " .. newNick .. ".") cblock.names[i] = newNick helper.sortList(cblock) break end end if cblock == blocks[blocks.active] then dirty.nicks = true end end elseif command == "MODE" then local cblock = helper.findChannel(block,args[1]) if #args == 2 then helper.addTextToBlock(cblock or block,"*",(cblock and "" or "[" .. args[1] .. "] ") .. name(prefix) .. " set mode".. ( #args[2] > 2 and "s" or "" ) .. " " .. tostring(args[2] or message) .. ".") else local setmode = {} local cumode = "+" args[2]:gsub(".", function(char) if char == "-" or char == "+" then cumode = char else table.insert(setmode, {cumode, char}) end end) local d = {} local users = {} for i = 3, #args do users[i-2] = args[i] end users[#users+1] = message local last local ctxt = "" for c = 1, #users do if not setmode[c] then break end local mode = setmode[c][2] local pfx = setmode[c][1]=="+" local key = mode == "o" and (pfx and "opped" or "deoped") or mode == "v" and (pfx and "voiced" or "devoiced") or mode == "q" and (pfx and "quieted" or "unquieted") or mode == "b" and (pfx and "banned" or "unbanned") or "set " .. setmode[c][1] .. mode .. " on" if last ~= key then if last then helper.addTextToBlock(cblock or block,"*",ctxt) end ctxt = (cblock and "" or "[" .. args[1] .. "] ") .. name(prefix) .. " " .. key last = key end ctxt = ctxt .. " " .. users[c] end if #ctxt > 0 then helper.addTextToBlock(cblock or block,"*",ctxt) end end elseif command == "QUIT" then local name = name(prefix) for i = 1,#block.children do local cblock = block.children[i] for i = 1,#cblock.names do if cblock.names[i]:gsub("^[" .. nprefix .. "]+","") == name then helper.addTextToBlock(cblock,"*", name .. " quit (" .. (message or "Quit") .. ").",theme.actions.part.color) table.remove(cblock.names,i) break end end if cblock == blocks[blocks.active] then dirty.nicks = true end end elseif command == "JOIN" then local name = name(prefix) if name == nick then helper.joinChannel(block,args[1]) else local cblock = helper.findChannel(block,args[1]) helper.addTextToBlock(cblock,"*",name .. " entered the room.",theme.actions.join.color) table.insert(cblock.names,name) helper.sortList(cblock) if cblock == blocks[blocks.active] then dirty.nicks = true end end elseif command == "PART" then local cblock = helper.findChannel(block,args[1]) local name = name(prefix) if name == nick then helper.closeChannel(cblock) else helper.addTextToBlock(cblock,"*",name .. " has left the room (quit: " .. (message or "Quit") .. ").",theme.actions.part.color) for i = 1,#cblock.names do if cblock.names[i]:gsub("^[" .. nprefix .. "]+","") == name then table.remove(cblock.names,i) break end end if cblock == blocks[blocks.active] then dirty.nicks = true end end elseif command == "TOPIC" then local cblock = helper.findChannel(block,args[1]) helper.addTextToBlock(cblock,"*",name(prefix) .. " has changed the topic to: " .. message) elseif command == "KICK" then local cblock = helper.findChannel(block,args[1]) helper.addTextToBlock(cblock,"*",name(prefix) .. " kicked " .. args[2],theme.actions.part.color) for i = 1,#cblock.names do if cblock.names[i]:gsub("^[" .. nprefix .. "]+","") == args[2] then table.remove(cblock.names,i) break end end if cblock == blocks[blocks.active] then dirty.nicks = true end elseif command == "PRIVMSG" then local channel = args[1] if not (block.support.CHANTYPES or default_support.CHANTYPES):find(args[1]:sub(1,1),nil,true) then channel = name(prefix) end local ctcp = message:match("^\1(.-)\1$") if ctcp then local ctcp, param = ctcp:match("^(%S+) ?(.-)$") ctcp = ctcp:upper() if ctcp ~= "ACTION" then helper.addTextToBlock(block,"*","[" .. name(prefix) .. "] CTCP " .. ctcp .. " " .. param) end if ctcp == "ACTION" then local cblock = helper.findOrJoinChannel(block,channel) helper.addTextToBlock(cblock,"*", name(prefix) .. " " .. param) elseif ctcp == "TIME" then helper.write(sock, "NOTICE " .. name(prefix) .. " :\001TIME " .. os.date() .. "\001") elseif ctcp == "VERSION" then helper.write(sock, "NOTICE " .. name(prefix) .. " :\001VERSION WocChat " .. version .. " [OpenComputers]\001") elseif ctcp == "PING" then helper.write(sock, "NOTICE " .. name(prefix) .. " :\001PING " .. param .. "\001") end else local cblock = helper.findOrJoinChannel(block,channel) if message:find(nick, nil, true) then if config.wocchat.notifysound then computer.beep() end helper.addTextToBlock(cblock, name(prefix), message, theme.actions.highlight.color) else helper.addTextToBlock(cblock, name(prefix), message) end end elseif command == "NOTICE" then local channel = args[1] if not (block.support.CHANTYPES or default_support.CHANTYPES):find(args[1]:sub(1,1),nil,true) then channel = name(prefix) end if message:find(nick, nil, true) then computer.beep() end if channel == prefix then helper.addTextToBlock(block, "*", "[NOTICE] " .. message) else local cblock = helper.findOrJoinChannel(block,channel) helper.addTextToBlock(cblock, "-" .. name(prefix) .. "-", message) end elseif command == "ERROR" then helper.addTextToBlock(block,"*","[ERROR] " .. message) elseif tonumber(command) and ignore[tonumber(command)] then elseif command == replies.RPL_WELCOME then helper.addTextToBlock(block,"*",message) if config.server[block.id] ~= nil and config.server[block.id].channels ~= nil then for channel in (config.server[block.id].channels .. ","):gmatch("(.-),") do sock:write(string.format("JOIN %s\r\n", channel)) end sock:flush() end elseif command == replies.RPL_YOURHOST then helper.addTextToBlock(block,"*",message) elseif command == replies.RPL_CREATED then helper.addTextToBlock(block,"*",message) elseif command == replies.RPL_MYINFO then elseif command == replies.RPL_ISUPPORT then for i = 2,#args do if args[i]:sub(1,1) == "-" then block.support[args[i]:sub(2)] = nil elseif args[i]:find("=",nil,true) then local parameter,value = args[i]:match("(.-)=(.+)") if value == "" then value = true end block.support[parameter] = value else block.support[args[i]] = true end if args[i] == "NETWORK" or args[i] == "-NETWORK" or args[i]:sub(1,8) == "NETWORK=" then helper.markDirty("blocks","title") end end elseif command == replies.RPL_LUSERCLIENT then helper.addTextToBlock(block,"*",message) elseif command == replies.RPL_LUSEROP then elseif command == replies.RPL_LUSERUNKNOWN then elseif command == replies.RPL_LUSERCHANNELS then elseif command == replies.RPL_LUSERME then helper.addTextToBlock(block,"*",message) elseif command == replies.RPL_AWAY then helper.addTextToBlock(block,"*",string.format("%s is away: %s", name(args[1]), message)) elseif command == replies.RPL_UNAWAY or command == replies.RPL_NOWAWAY then helper.addTextToBlock(block,"*",message) elseif command == replies.RPL_WHOISUSER then local nick = args[2]:lower() whois[nick].nick = args[2] whois[nick].user = args[3] whois[nick].host = args[4] whois[nick].realName = message elseif command == replies.RPL_WHOISSERVER then local nick = args[2]:lower() whois[nick].server = args[3] whois[nick].serverInfo = message elseif command == replies.RPL_WHOISOPERATOR then local nick = args[2]:lower() whois[nick].isOperator = true elseif command == replies.RPL_WHOISIDLE then local nick = args[2]:lower() whois[nick].idle = tonumber(args[3]) elseif command == replies.RPL_WHOISSECURE then local nick = args[2]:lower() whois[nick].secureconn = "Is using a secure connection" elseif command == replies.RPL_ENDOFWHOIS then local nick = args[2]:lower() local info = whois[nick] if info.nick then helper.addTextToBlock(block,"*","Nick: " .. info.nick) end if info.user then helper.addTextToBlock(block,"*","User name: " .. info.user) end if info.realName then helper.addTextToBlock(block,"*","Real name: " .. info.realName) end if info.host then helper.addTextToBlock(block,"*","Host: " .. info.host) end if info.server then helper.addTextToBlock(block,"*","Server: " .. info.server .. (info.serverInfo and (" (" .. info.serverInfo .. ")") or "")) end if info.secureconn then helper.addTextToBlock(block,"*",info.secureconn) end if info.channels then helper.addTextToBlock(block,"*","Channels: " .. info.channels) end if info.idle then helper.addTextToBlock(block,"*","Idle for: " .. info.idle) end whois[nick] = nil elseif command == replies.RPL_WHOISCHANNELS then local nick = args[2]:lower() whois[nick].channels = message elseif command == replies.RPL_CHANNELMODEIS then helper.addTextToBlock(block,"*","Channel mode for " .. args[1] .. ": " .. args[2] .. " (" .. args[3] .. ")") elseif command == replies.RPL_NOTOPIC then local cblock = helper.findChannel(block,args[2]) helper.addTextToBlock(cblock,"*","No topic is set for " .. args[2] .. ".",theme.actions.title.color) elseif command == replies.RPL_TOPIC then local cblock = helper.findChannel(block,args[2]) cblock.title = message helper.addTextToBlock(cblock,"*","Topic for " .. args[2] .. ": " .. message,theme.actions.title.color) if blocks[blocks.active] == cblock then dirty.title = true end elseif command == replies.RPL_TOPICWHOTIME then local cblock = helper.findChannel(block,args[2]) helper.addTextToBlock(cblock,"*","Topic set by " .. args[3] .. " at " .. os.date("%a %b %d %H:%M:%S %Y",tonumber(args[4])),theme.actions.title.color) if blocks[blocks.active] == cblock then dirty.title = true end elseif command == replies.RPL_NAMREPLY then local channel = args[3] for name in (message .. " "):gmatch("(.-) ") do table.insert(names[channel], name) end elseif command == replies.RPL_ENDOFNAMES then local channel = args[2] local cblock = helper.findChannel(block,channel) cblock.names = names[channel] helper.sortList(cblock) if blocks[blocks.active] == cblock then dirty.nicks = true end names[channel] = nil elseif command == replies.RPL_MOTDSTART then if config.wocchat.showmotd then helper.addTextToBlock(block,"*",message .. args[1]) end elseif command == replies.RPL_MOTD then if config.wocchat.showmotd then helper.addTextToBlock(block,"*",message) end elseif command == replies.RPL_ENDOFMOTD then elseif command == replies.RPL_HELPSTART or command == replies.RPL_HELPTXT or command == replies.RPL_ENDOFHELP then helper.addTextToBlock(block,"*",message) elseif command == replies.ERR_BANLISTFULL or command == replies.ERR_BANNEDFROMCHAN or command == replies.ERR_CANNOTSENDTOCHAN or command == replies.ERR_CHANNELISFULL or command == replies.ERR_CHANOPRIVSNEEDED or command == replies.ERR_ERRONEUSNICKNAME or command == replies.ERR_INVITEONLYCHAN or command == replies.ERR_NICKCOLLISION or command == replies.ERR_NOSUCHNICK or command == replies.ERR_NOTONCHANNEL or command == replies.ERR_UNIQOPRIVSNEEDED or command == replies.ERR_UNKNOWNMODE or command == replies.ERR_USERNOTINCHANNEL or command == replies.ERR_WASNOSUCHNICK or command == replies.ERR_MODELOCK then helper.addTextToBlock(block,"*","[ERROR]: " .. message) elseif tonumber(command) and (tonumber(command) >= 200 and tonumber(command) < 400) then helper.addTextToBlock(block,"*","[Response " .. command .. "] " .. table.concat(args, ", ") .. ": " .. (message or "")) elseif tonumber(command) and (tonumber(command) >= 400 and tonumber(command) < 600) then helper.addTextToBlock(block,"*","[Error] " .. table.concat(args, ", ") .. ": " .. (message or "")) else helper.addTextToBlock(block,"*","Unhandled command: " .. command .. ": " .. (message or "")) end end local commands = {} function commands.help(...) local names = {} for k,v in pairs(commands) do names[#names+1] = k end table.sort(names) helper.addText("","Commands Available: " .. table.concat(names,", ")) end function commands.server(...) local args,opts = shell.parse(...) if #args == 0 then helper.addText("","Usage: /server address[:port]") else if not args[1]:find(":",nil,true) then args[1] = args[1] .. ":6667" end config.server["_tmpserver"] = {name=args[1]:match("(.*):"),server=args[1]} helper.joinServer("_tmpserver") config.server["_tmpserver"] = nil redraw() end end function commands.connect(...) local args,opts = shell.parse(...) if #args == 0 then helper.addText("","Usage: /connect serverid") elseif config.server[args[1]] == nil then helper.addText("","No server named '" .. args[1] .. "'",theme.actions.error.color) else helper.joinServer(args[1]) redraw() end end function commands.join(...) local args = {...} if #args == 0 then helper.addText("","Usage: /join channel1 channel2 channel3 ...") else local sock = helper.getSocket() if sock == nil then helper.addText("","/join cannot be performed on this block",theme.actions.error.color) else for i = 1,#args do sock:write(string.format("JOIN %s\r\n", args[i])) end sock:flush() end end end function commands.part(...) local args = {...} local sock = helper.getSocket() if blocks[blocks.active].type == "dead_channel" then helper.closeChannel(blocks[blocks.active]) elseif sock == nil then helper.addText("","/part cannot be performed on this block",theme.actions.error.color) else if #args == 0 then if blocks[blocks.active].type ~= "channel" then helper.addText("","/part cannot be performed on this block",theme.actions.error.color) elseif not (blocks[blocks.active].parent.support.CHANTYPES or default_support.CHANTYPES):find(blocks[blocks.active].name:sub(1,1),nil,true) then helper.closeChannel(blocks[blocks.active]) else helper.write(sock, string.format("PART %s", blocks[blocks.active].name)) end else for i = 1,#args do sock:write(string.format("PART %s\r\n", args[i])) end sock:flush() end end end function commands.quit(...) local args = {...} local sock = helper.getSocket() if sock == nil then helper.addText("","/quit cannot be performed on this block",theme.actions.error.color) else helper.write(sock, "QUIT :" .. (#args > 0 and table.concat(args," ") or config.default.quit)) end end function commands.raw(...) local args = {...} if #args ~= 0 then local sock = helper.getSocket() if sock == nil then helper.addText("","/raw cannot be performed on this block",theme.actions.error.color) else helper.write(sock, string.format("%s", table.concat(args," "))) end end end function commands.me(...) local args = {...} if #args == 0 then helper.addText("","Usage: /me <action>") else local sock = helper.getSocket() if blocks[blocks.active].type ~= "channel" or sock == nil then helper.addText("","/me cannot be performed on this block",theme.actions.error.color) else helper.write(sock, string.format("PRIVMSG %s :\1ACTION %s\1", blocks[blocks.active].name, table.concat(args," "))) helper.addText("*",blocks[blocks.active].parent.nick .. " " .. table.concat(args," ")) end end end function commands.redraw(...) local args = {...} if #args == 0 then helper.markDirty("blocks","window","nicks","title") else local good = true for i = 1,#args do if args[i] ~= "blocks" and args[i] ~= "window" and args[i] ~= "nicks" and args[i] ~= "title" then helper.addText("","Invalid type '" .. args[i] .. "'",theme.actions.error.color) good = false end end if not good then return end for i = 1,#args do dirty[args[i]] = true end end redraw() end function commands.msg(...) local args = {...} if #args < 2 then helper.addText("","Usage: /msg <nickname> <message>") else local sock = helper.getSocket() if sock == nil then helper.addText("","/msg cannot be performed on this block",theme.actions.error.color) else helper.write(sock, string.format("PRIVMSG %s :%s", args[1], table.concat(args," ",2))) local block = blocks[blocks.active] if block.parent ~= nil then block = block.parent end if (block.support.CHANTYPES or default_support.CHANTYPES):find(args[1]:sub(1,1),nil,true) then local cblock = helper.findChannel(block,args[1]) if cblock then helper.addTextToBlock(cblock,block.nick,table.concat(args," ",2)) end else local cblock = helper.findOrJoinChannel(block,args[1]) helper.addTextToBlock(cblock,block.nick,table.concat(args," ",2)) end end end end function commands.clear(...) blocks[blocks.active].text = {} dirty.window = true end function commands.nick(...) local args = {...} if #args == 0 then helper.addText("","Usage: /nick nickname") else local sock = helper.getSocket() if sock == nil then helper.addText("","/nick cannot be performed on this block",theme.actions.error.color) else helper.write(sock, string.format("NICK %s", args[1])) end end end local loadedConfig=false local function main() if not fs.exists("/etc/wocchat.cfg") or options["dl-config"] then print("Downloading config ...") local f, reason = io.open("/etc/wocchat.cfg", "wb") if not f then errprint("Failed to open file for writing: " .. reason) return end local result, response = pcall(internet.request, "https://raw.githubusercontent.com/OpenPrograms/gamax92-Programs/master/wocchat/wocchat.cfg") if result then local result, reason = pcall(function() for chunk in response do f:write(chunk) end end) f:close() if not result then fs.remove("/etc/wocchat.cfg") errprint("HTTP request failed: " .. reason) return end else f:close() fs.remove("/etc/wocchat.cfg") errprint("HTTP request failed: " .. response) return end end print("Loading config ...") loadConfig() if config.default.nick == nil then while true do term.write("Enter your default nickname: ") local nick = term.read() if nick == nil or nick == "" then return end nick = text.trim(nick) if nick ~= "" then config.default.nick = text.trim(nick) break else print("Invalid nickname.") end end end if config.default.user == nil then config.default.user = config.default.nick:lower() end if config.default.realname == nil then config.default.realname = config.default.nick .. " [WocChat]" end -- Version upgrade assistant if config.wocchat.version == nil then config.wocchat.version = "v0.0.2" if config.default.realname:sub(-15) == "[OpenComputers]" then config.default.realname = config.default.realname:match("(.*)%[OpenComputers%]") .. "[WocChat]" end end if config.wocchat.version == "v0.0.2" then config.wocchat.version = "v0.0.3" config.wocchat.notifysound = true config["base.theme"].actions.highlight = {color = 15} config["base.theme"].tree.entry.prefix.last = "└─" config["base.theme"].tree.entry.prefix.str = "├─" end loadedConfig=true print("Saving screen ...") saveScreen() if config.wocchat.usetree == nil then config.wocchat.usetree = (screen.width > 80 and screen.height > 25) end gpu.setDepth(gpu.maxDepth()) gpu.setForeground(0xFFFFFF) gpu.setBackground(0) gpu.fill(1,1,screen.width,screen.height," ") gpu.set(screen.width/2-15,screen.height/2,"Loading theme, please wait ...") local i=0 while theme[i] ~= nil do gpu.setPaletteColor(i,theme[i]) gpu.fill(screen.width/2-16,screen.height/2+1,i*2+2,1,"█") i=i+1 end if newterm then term.screen().setPrecise(false) else component.screen.setPrecise(false) end term.setCursor(1,1) local scrolldrag=false function persist.mouse(event, addr, x, y, button) local ok,err = pcall(function() if event == "touch" then if config.wocchat.usetree then if y <= #blocks and x <= persist.window_x-2 and y ~= blocks.active then blocks.active = y blocks[blocks.active].new = nil helper.markDirty("blocks","window","nicks","title") redraw() end else if y >= screen.height and x > 1 then local bx = 2 for i = 1,#blocks do local block = blocks[i] local name = block.name if block.type == "server" or block.type == "dead_server" then name = block.support.NETWORK or block.name end local bnx = bx+unicode.wlen(name) if x >= bx and x <= bnx then blocks.active = i helper.markDirty("blocks","window","nicks","title") redraw() break end bx=bnx+1 end end end end if ((event == "touch" and x >= screen.width) or (event == "drag" and scrolldrag)) and y <= (config.wocchat.usetree and screen.height or screen.height - 1) and blocks[blocks.active].names ~= nil then local height = (config.wocchat.usetree and screen.height or screen.height - 1) blocks[blocks.active].scroll = math.floor((y-1)/(height-1)*(#blocks[blocks.active].names-height)+1) dirty.nicks = true scrolldrag = true end if event == "scroll" then if x > screen.width-persist.listwidth and y <= screen.height+(config.wocchat.usetree and 0 or 1) then blocks[blocks.active].scroll = blocks[blocks.active].scroll - button dirty.nicks = true end end if event == "drop" then scrolldrag = false end end) if not ok then helper.addText("EventErr",err,theme.actions.error.color) end end event.listen("touch",persist.mouse) event.listen("drag",persist.mouse) event.listen("drop",persist.mouse) event.listen("scroll",persist.mouse) persist.timer = event.timer(0.5, function() for i = 1,#blocks do local block = blocks[i] if block.sock ~= nil then local sock = block.sock repeat local ok, line = pcall(sock.read, sock) if ok then if not line then helper.addTextToBlock(block,"*","Connection lost.") pcall(sock.close, sock) block.sock = nil block.type = "dead_" .. block.type for j=1,#block.children do block.children[j].type = "dead_" .. block.children[j].type end dirty.blocks = true if blocks[blocks.active] == block then dirty.window = true end break end line = text.trim(line) -- get rid of trailing \r local origline = line local match, prefix = line:match("^(:(%S+) )") if match then line = line:sub(#match + 1) end local match, command = line:match("^(([^:]%S*))") if match then line = line:sub(#match + 1) end local args = {} local message repeat local match, arg = line:match("^( ([^:]%S*))") -- HACK: sometimes arguments are in the message if not match and #args == 0 then message = line:match("^ :(.*)$") if message then line = " " .. message match, arg = line:match("^( ([^:]%S*))") end end if match then line = line:sub(#match + 1) table.insert(args, arg) end until not match message = message or line:match("^ :(.*)$") local hco, hcerr = pcall(handleCommand, block, prefix, command, args, message) if not hco then helper.addTextToBlock(block,"LuaError",hcerr,theme.actions.error.color) end if config.wocchat.showraw then helper.addTextToBlock(blocks[1],"RAW",origline) end end until not ok end end end, math.huge) persist.timer = event.timer(0.05, function() if dirty.blocks or dirty.title or dirty.window or dirty.nicks then redraw() end end, math.huge) redraw(true) for k,v in pairs(config.server) do if v.autojoin then helper.joinServer(k) end end local history = {nowrap=true} while true do if dirty.blocks or dirty.title or dirty.window or dirty.nicks then redraw() end setBackground(theme.textbar.color) setForeground(theme.textbar.text.color) local line = term.read(history,nil,function(line,pos) local block = blocks[blocks.active] if block.names == nil then return {} end local nprefix = (block.parent.support.PREFIX or default_support.PREFIX):match("%)(.*)") local line, extra = line:sub(1,pos-1), line:sub(pos) local base, word = (" " .. line):match("(.*%s)(.*)") base,word = base:sub(2),word:lower() local list = {} for i = 1,#block.names do local name = block.names[i]:gsub("^[" .. nprefix .. "]+","") if name:sub(1,#word):lower() == word then list[#list+1] = base .. name .. (base == "" and ": " or "") .. extra end end table.sort(list,function(a,b) return a:lower() < b:lower() end) if #list == 1 then list[2] = list[1] -- Prevent term.read stupidity end return list end) if line ~= nil then line = line:gsub("[\r\n]","") end if line == "/exit" then break end if line == "" then elseif line:sub(1,1) == "/" and line:sub(1,2) ~= "//" then local parse = {} for part in (line:sub(2) .. " "):gmatch("(.-) ") do parse[#parse+1] = part end if commands[parse[1]] ~= nil then local ok, err = pcall(commands[parse[1]], table.unpack(parse,2)) if not ok then helper.addText("CmdError",err,theme.actions.error.color) end else local sock = helper.getSocket() if sock == nil then helper.addText("","/" .. parse[1] .. " cannot be performed on this block",theme.actions.error.color) else helper.write(sock, table.concat(parse, " ")) end end elseif blocks[blocks.active].type == "channel" then if line:sub(1,2) == "//" then line = line:sub(2) end helper.write(blocks[blocks.active].parent.sock, string.format("PRIVMSG %s :%s",blocks[blocks.active].name,line)) helper.addText(blocks[blocks.active].parent.nick,line) else helper.addText("","Cannot type on this window") end end end -- Hijack needed for term.read local old_getPrimary if newterm then local w,h,dx,dy,x,y = term.getViewport() local oldwindow = { gpu=term.gpu(), screen=term.screen(), keyboard=term.keyboard(), w=w, h=h, dx=dx, dy=dy, x=x, y=y } local window = term.internal.open() window.x=x window.y=y term.bind(gpu, term.screen(), term.keyboard(), window) process.info().data.window = window customGPU.window = window else old_getPrimary = component.getPrimary function component.getPrimary(componentType) checkArg(1, componentType, "string") assert(component.isAvailable(componentType), "no primary '" .. componentType .. "' available") if componentType == "gpu" then return customGPU.gpu or old_getPrimary(componentType) end return old_getPrimary(componentType) end end local stat, err = xpcall(main,debug.traceback) if newterm then process.info().data.window = nil else component.getPrimary = old_getPrimary end for i = 1,#blocks do if blocks[i].sock then pcall(blocks[i].sock.write, blocks[i].sock, "QUIT :" .. config.default.quit .. "\r\n") pcall(blocks[i].sock.close, blocks[i].sock) end end if persist.mouse then event.ignore("touch",persist.mouse) event.ignore("drag",persist.mouse) event.ignore("drop",persist.mouse) event.ignore("scroll",persist.mouse) end if persist.timer then event.cancel(persist.timer) end if screen then restoreScreen() end if loadedConfig then print("Saving config ...") local sok,srr = saveConfig() if not sok then errprint("Failed to save config: " .. srr) end else print("Failed to load configuration, skipping save.") end if not stat then errprint(err) end