diff options
-rw-r--r-- | .clang-format | 32 | ||||
-rw-r--r-- | .gitignore | 11 | ||||
-rw-r--r-- | .gitmodules | 3 | ||||
-rw-r--r-- | CMakeLists.txt | 294 | ||||
-rw-r--r-- | LICENSE | 12 | ||||
-rw-r--r-- | NEWS | 330 | ||||
-rw-r--r-- | README.adoc | 210 | ||||
-rw-r--r-- | common.c | 1096 | ||||
-rw-r--r-- | config.h.in | 15 | ||||
m--------- | liberty | 0 | ||||
-rwxr-xr-x | plugins/xB/calc | 241 | ||||
-rwxr-xr-x | plugins/xB/coin | 128 | ||||
-rwxr-xr-x | plugins/xB/eval | 312 | ||||
-rwxr-xr-x | plugins/xB/factoids | 177 | ||||
-rwxr-xr-x | plugins/xB/pomodoro | 502 | ||||
-rwxr-xr-x | plugins/xB/script | 2310 | ||||
-rwxr-xr-x | plugins/xB/seen | 160 | ||||
-rwxr-xr-x | plugins/xB/seen-import-xC.pl | 39 | ||||
-rwxr-xr-x | plugins/xB/youtube | 111 | ||||
-rw-r--r-- | plugins/xC/auto-rejoin.lua | 48 | ||||
-rw-r--r-- | plugins/xC/censor.lua | 90 | ||||
-rw-r--r-- | plugins/xC/fancy-prompt.lua | 113 | ||||
-rw-r--r-- | plugins/xC/last-fm.lua | 178 | ||||
-rw-r--r-- | plugins/xC/ping-timeout.lua | 32 | ||||
-rw-r--r-- | plugins/xC/prime.lua | 68 | ||||
-rw-r--r-- | plugins/xC/slack.lua | 147 | ||||
-rw-r--r-- | plugins/xC/thin-cursor.lua | 27 | ||||
-rw-r--r-- | plugins/xC/utm-filter.lua | 66 | ||||
-rwxr-xr-x | test | 50 | ||||
-rwxr-xr-x | test-nick-colors | 26 | ||||
-rwxr-xr-x | test-static | 14 | ||||
-rw-r--r-- | xB.adoc | 104 | ||||
-rw-r--r-- | xB.c | 2064 | ||||
-rw-r--r-- | xC-gen-proto-c.awk | 325 | ||||
-rw-r--r-- | xC-gen-proto-go.awk | 519 | ||||
-rw-r--r-- | xC-gen-proto-js.awk | 223 | ||||
-rw-r--r-- | xC-gen-proto.awk | 305 | ||||
-rw-r--r-- | xC-proto | 206 | ||||
-rw-r--r-- | xC.adoc | 127 | ||||
-rw-r--r-- | xC.c | 15971 | ||||
-rw-r--r-- | xC.webp | bin | 0 -> 34548 bytes | |||
-rwxr-xr-x | xD-gen-replies.awk | 29 | ||||
-rw-r--r-- | xD-replies | 93 | ||||
-rw-r--r-- | xD.adoc | 53 | ||||
-rw-r--r-- | xD.c | 4106 | ||||
-rw-r--r-- | xF.c | 172 | ||||
-rw-r--r-- | xF.svg | 36 | ||||
-rw-r--r-- | xP.webp | bin | 0 -> 43094 bytes | |||
-rw-r--r-- | xP/.gitignore | 4 | ||||
-rw-r--r-- | xP/Makefile | 18 | ||||
-rw-r--r-- | xP/gen-ircfmt.awk | 89 | ||||
-rw-r--r-- | xP/go.mod | 10 | ||||
-rw-r--r-- | xP/go.sum | 62 | ||||
-rw-r--r-- | xP/public/ircfmt.woff2 | bin | 0 -> 1240 bytes | |||
-rw-r--r-- | xP/public/xP.css | 257 | ||||
-rw-r--r-- | xP/public/xP.js | 1108 | ||||
-rw-r--r-- | xP/xP.go | 299 |
57 files changed, 33022 insertions, 0 deletions
diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..27838ac --- /dev/null +++ b/.clang-format @@ -0,0 +1,32 @@ +# clang-format is fairly limited, and these rules are approximate: +# - array initializers can get terribly mangled with clang-format 12.0, +# - sometimes it still aligns with space characters, +# - struct name NL { NL ... NL } NL name; is unachievable. +BasedOnStyle: GNU +ColumnLimit: 80 +IndentWidth: 4 +TabWidth: 4 +UseTab: ForContinuationAndIndentation +BreakBeforeBraces: Allman +SpaceAfterCStyleCast: true +AlignAfterOpenBracket: DontAlign +AlignOperands: DontAlign +AlignConsecutiveMacros: Consecutive +AllowAllArgumentsOnNextLine: false +AllowAllParametersOfDeclarationOnNextLine: false +IndentGotoLabels: false + +# IncludeCategories has some potential, but it may also break the build. +# Note that the documentation says the value should be "Never". +SortIncludes: false + +# This is a compromise, it generally works out aesthetically better. +BinPackArguments: false + +# Unfortunately, this can't be told to align to column 40 or so. +SpacesBeforeTrailingComments: 2 + +# liberty-specific macro body wrappers. +MacroBlockBegin: "BLOCK_START" +MacroBlockEnd: "BLOCK_END" +ForEachMacros: ["LIST_FOR_EACH"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba08178 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Build files +/build + +# Qt Creator files +/CMakeLists.txt.user* +/xK.config +/xK.files +/xK.creator* +/xK.includes +/xK.cflags +/xK.cxxflags diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fc28e82 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "liberty"] + path = liberty + url = https://git.janouch.name/p/liberty.git diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d316c15 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,294 @@ +# Ubuntu 18.04 LTS and OpenBSD 6.4 +cmake_minimum_required (VERSION 3.10) +project (xK VERSION 1.5.0 + DESCRIPTION "IRC daemon, bot, TUI client and its web frontend" LANGUAGES C) + +# Options +option (WANT_READLINE "Use GNU Readline for the UI (better)" ON) +option (WANT_LIBEDIT "Use BSD libedit for the UI" OFF) +option (WANT_XF "Build xF" OFF) + +# Moar warnings +set (CMAKE_C_STANDARD 99) +set (CMAKE_C_STANDARD_REQUIRED ON) +set (CMAKE_C_EXTENSIONS OFF) + +if ("${CMAKE_C_COMPILER_ID}" MATCHES "GNU" OR CMAKE_COMPILER_IS_GNUCC) + # -Wunused-function is pretty annoying here, as everything is static + set (CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wextra -Wno-unused-function") +endif () + +# Version +set (project_version "${PROJECT_VERSION}") + +# Try to append commit ID if it follows a version tag. It might be nicer if +# we could also detect dirty worktrees but that's very hard to get right. +# If we didn't need this for CPack, we could use add_custom_command to generate +# a version source/include file. +find_package (Git) +set (git_head "${PROJECT_SOURCE_DIR}/.git/HEAD") +if (GIT_FOUND AND EXISTS "${git_head}") + configure_file ("${git_head}" git-head.tag COPYONLY) + file (READ "${git_head}" git_head_content) + if (git_head_content MATCHES "^ref: ([^\r\n]+)") + set (git_ref "${PROJECT_SOURCE_DIR}/.git/${CMAKE_MATCH_1}") + if (EXISTS "${git_ref}") + configure_file ("${git_ref}" git-ref.tag COPYONLY) + endif () + endif () + + execute_process (COMMAND ${GIT_EXECUTABLE} describe --tags --match v* + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} + RESULT_VARIABLE git_describe_result + OUTPUT_VARIABLE git_describe OUTPUT_STRIP_TRAILING_WHITESPACE) + if (NOT git_describe_result) + string (REGEX REPLACE "^v" "" project_version "${git_describe}") + endif () +endif () + +# Dashes make filenames confusing and upset packaging software +string (REPLACE "-" "+" project_version_safe "${project_version}") + +# Dependencies +set (CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/liberty/cmake) +include (AddThreads) + +find_package (PkgConfig REQUIRED) +pkg_check_modules (libssl REQUIRED libssl libcrypto) +list (APPEND project_libraries ${libssl_LIBRARIES}) +include_directories (${libssl_INCLUDE_DIRS}) +link_directories (${libssl_LIBRARY_DIRS}) + +if ("${CMAKE_SYSTEM_NAME}" MATCHES "BSD") + # Need this for SIGWINCH in FreeBSD and OpenBSD respectively; + # our POSIX version macros make it undefined + add_definitions (-D__BSD_VISIBLE=1 -D_BSD_SOURCE=1) +elseif (APPLE) + add_definitions (-D_DARWIN_C_SOURCE) +endif () + +# -lrt is only for glibc < 2.17 +# -liconv may or may not be a part of libc +# -lm may or may not be a part of libc +foreach (extra iconv rt m) + find_library (extra_lib_${extra} ${extra}) + if (extra_lib_${extra}) + list (APPEND project_libraries ${extra_lib_${extra}}) + endif () +endforeach () + +include (CheckCSourceRuns) +set (CMAKE_REQUIRED_LIBRARIES ${project_libraries}) +get_property (CMAKE_REQUIRED_INCLUDES + DIRECTORY "${PROJECT_SOURCE_DIR}" PROPERTY INCLUDE_DIRECTORIES) +CHECK_C_SOURCE_RUNS ("#include <iconv.h> + int main () { return iconv_open (\"UTF-8//TRANSLIT\", \"ISO-8859-1\") + == (iconv_t) -1; }" ICONV_ACCEPTS_TRANSLIT) + +# Dependencies for xC +pkg_check_modules (libffi REQUIRED libffi) +list (APPEND xC_libraries ${libffi_LIBRARIES}) +include_directories (${libffi_INCLUDE_DIRS}) +link_directories (${libffi_LIBRARY_DIRS}) + +# XXX: other Lua versions may be acceptable, don't know yet +pkg_search_module (lua lua53 lua5.3 lua-5.3 lua54 lua5.4 lua-5.4 lua>=5.3) +option (WITH_LUA "Enable support for Lua plugins" ${lua_FOUND}) + +if (WITH_LUA) + if (NOT lua_FOUND) + message (FATAL_ERROR "Lua library not found") + endif () + + list (APPEND xC_libraries ${lua_LIBRARIES}) + include_directories (${lua_INCLUDE_DIRS}) + link_directories (${lua_LIBRARY_DIRS}) +endif () + +find_package (Curses) +pkg_check_modules (ncursesw ncursesw) +if (ncursesw_FOUND) + list (APPEND xC_libraries ${ncursesw_LIBRARIES}) + include_directories (${ncursesw_INCLUDE_DIRS}) +elseif (CURSES_FOUND) + list (APPEND xC_libraries ${CURSES_LIBRARY}) + include_directories (${CURSES_INCLUDE_DIR}) +else () + message (SEND_ERROR "Curses not found") +endif () + +if ((WANT_READLINE AND WANT_LIBEDIT) OR (NOT WANT_READLINE AND NOT WANT_LIBEDIT)) + message (SEND_ERROR "You have to choose either GNU Readline or libedit") +elseif (WANT_READLINE) + pkg_check_modules (readline readline) + + # OpenBSD's default readline is too old + if ("${CMAKE_SYSTEM_NAME}" MATCHES "OpenBSD") + include_directories (${OPENBSD_LOCALBASE}/include/ereadline) + list (APPEND xC_libraries ereadline) + elseif (readline_FOUND) + list (APPEND xC_libraries ${readline_LIBRARIES}) + include_directories (${readline_INCLUDE_DIRS}) + link_directories (${readline_LIBRARY_DIRS}) + else () + list (APPEND xC_libraries readline) + endif () +elseif (WANT_LIBEDIT) + pkg_check_modules (libedit REQUIRED libedit) + list (APPEND xC_libraries ${libedit_LIBRARIES}) + include_directories (${libedit_INCLUDE_DIRS}) +endif () + +# Generate a configuration file +set (HAVE_READLINE "${WANT_READLINE}") +set (HAVE_EDITLINE "${WANT_LIBEDIT}") +set (HAVE_LUA "${WITH_LUA}") + +include (GNUInstallDirs) +set (project_config ${PROJECT_BINARY_DIR}/config.h) +configure_file (${PROJECT_SOURCE_DIR}/config.h.in ${project_config}) +include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR}) + +# Generate IRC replies--we need a custom target because of the multiple outputs +add_custom_command (OUTPUT xD-replies.c xD.msg + COMMAND env LC_ALL=C awk + -f ${PROJECT_SOURCE_DIR}/xD-gen-replies.awk + ${PROJECT_SOURCE_DIR}/xD-replies > xD-replies.c + DEPENDS + ${PROJECT_SOURCE_DIR}/xD-gen-replies.awk + ${PROJECT_SOURCE_DIR}/xD-replies + COMMENT "Generating files from the list of server numerics") +add_custom_target (replies DEPENDS ${PROJECT_BINARY_DIR}/xD-replies.c) + +add_custom_command (OUTPUT xC-proto.c + COMMAND env LC_ALL=C awk + -f ${PROJECT_SOURCE_DIR}/xC-gen-proto.awk + -f ${PROJECT_SOURCE_DIR}/xC-gen-proto-c.awk + ${PROJECT_SOURCE_DIR}/xC-proto > xC-proto.c + DEPENDS + ${PROJECT_SOURCE_DIR}/xC-gen-proto.awk + ${PROJECT_SOURCE_DIR}/xC-gen-proto-c.awk + ${PROJECT_SOURCE_DIR}/xC-proto + COMMENT "Generating xC relay protocol code") +add_custom_target (xC-proto DEPENDS ${PROJECT_BINARY_DIR}/xC-proto.c) + +# Build +foreach (name xB xC xD) + add_executable (${name} ${name}.c ${project_config}) + target_link_libraries (${name} ${project_libraries}) + add_threads (${name}) +endforeach () + +add_dependencies (xD replies) +add_dependencies (xC replies xC-proto) +target_link_libraries (xC ${xC_libraries}) + +if (WANT_XF) + pkg_check_modules (x11 REQUIRED x11 xrender xft fontconfig) + include_directories (${x11_INCLUDE_DIRS}) + link_directories (${x11_LIBRARY_DIRS}) + + add_executable (xF xF.c ${project_config}) + add_dependencies (xF xC-proto) + target_link_libraries (xF ${x11_LIBRARIES} ${project_libraries}) + add_threads (xF) +endif () + +# Tests +include (CTest) +if (BUILD_TESTING) + add_executable (test-xC $<TARGET_PROPERTY:xC,SOURCES>) + set_target_properties (test-xC PROPERTIES COMPILE_DEFINITIONS TESTING) + target_link_libraries (test-xC $<TARGET_PROPERTY:xC,LINK_LIBRARIES>) + add_threads (test-xC) + add_dependencies (test-xC replies) + + add_test (NAME test-xC COMMAND test-xC) + add_test (NAME custom-static-analysis + COMMAND ${PROJECT_SOURCE_DIR}/test-static) +endif () + +# Various clang-based diagnostics, loads of fake positives and spam +file (GLOB clang_tidy_sources *.c) +set (clang_tidy_checks misc-* readability-* + -readability-braces-around-statements + -readability-named-parameter) +string (REPLACE ";" "," clang_tidy_checks "${clang_tidy_checks}") + +set (CMAKE_EXPORT_COMPILE_COMMANDS ON) +add_custom_target (clang-tidy + COMMAND clang-tidy -p ${PROJECT_BINARY_DIR} -checks=${clang_tidy_checks} + ${clang_tidy_sources} 1>&2 + USES_TERMINAL + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}) + +# Installation +install (TARGETS xB xC xD DESTINATION ${CMAKE_INSTALL_BINDIR}) +install (FILES LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR}) +install (DIRECTORY plugins/xB/ + DESTINATION ${CMAKE_INSTALL_DATADIR}/xB/plugins USE_SOURCE_PERMISSIONS) +install (DIRECTORY plugins/xC/ + DESTINATION ${CMAKE_INSTALL_DATADIR}/xC/plugins) + +# Generate documentation from text markup +find_program (ASCIIDOCTOR_EXECUTABLE asciidoctor) +find_program (A2X_EXECUTABLE a2x) +if (NOT ASCIIDOCTOR_EXECUTABLE AND NOT A2X_EXECUTABLE) + message (WARNING "Neither asciidoctor nor a2x were found, " + "falling back to a substandard manual page generator") +endif () + +foreach (page xB xC xD) + set (page_output "${PROJECT_BINARY_DIR}/${page}.1") + list (APPEND project_MAN_PAGES "${page_output}") + if (ASCIIDOCTOR_EXECUTABLE) + add_custom_command (OUTPUT ${page_output} + COMMAND ${ASCIIDOCTOR_EXECUTABLE} -b manpage + -a release-version=${project_version} + -o "${page_output}" + "${PROJECT_SOURCE_DIR}/${page}.adoc" + DEPENDS ${page}.adoc + COMMENT "Generating man page for ${page}" VERBATIM) + elseif (A2X_EXECUTABLE) + add_custom_command (OUTPUT ${page_output} + COMMAND ${A2X_EXECUTABLE} --doctype manpage --format manpage + -a release-version=${project_version} + -D "${PROJECT_BINARY_DIR}" + "${PROJECT_SOURCE_DIR}/${page}.adoc" + DEPENDS ${page}.adoc + COMMENT "Generating man page for ${page}" VERBATIM) + else () + set (ASCIIMAN ${PROJECT_SOURCE_DIR}/liberty/tools/asciiman.awk) + add_custom_command (OUTPUT ${page_output} + COMMAND env LC_ALL=C awk -f ${ASCIIMAN} + "${PROJECT_SOURCE_DIR}/${page}.adoc" > ${page_output} + DEPENDS ${page}.adoc ${ASCIIMAN} + COMMENT "Generating man page for ${page}" VERBATIM) + endif () +endforeach () + +add_custom_target (docs ALL DEPENDS ${project_MAN_PAGES}) + +foreach (page ${project_MAN_PAGES}) + string (REGEX MATCH "\\.([0-9])$" manpage_suffix "${page}") + install (FILES "${page}" + DESTINATION "${CMAKE_INSTALL_MANDIR}/man${CMAKE_MATCH_1}") +endforeach () + +# CPack +set (CPACK_PACKAGE_VERSION "${project_version_safe}") +set (CPACK_PACKAGE_VENDOR "Premysl Eric Janouch") +set (CPACK_PACKAGE_CONTACT "Přemysl Eric Janouch <p@janouch.name>") +set (CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE") + +set (CPACK_GENERATOR "TGZ;ZIP") +set (CPACK_PACKAGE_FILE_NAME + "${PROJECT_NAME}-${project_version_safe}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}") +set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME}-${project_version_safe}") + +set (CPACK_SOURCE_GENERATOR "TGZ;ZIP") +set (CPACK_SOURCE_IGNORE_FILES "/\\\\.git;/build;/CMakeLists.txt.user") +set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${project_version_safe}") + +set (CPACK_SET_DESTDIR TRUE) +include (CPack) @@ -0,0 +1,12 @@ +Copyright (c) 2014 - 2022, Přemysl Eric Janouch <p@janouch.name> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. @@ -0,0 +1,330 @@ +2.0.0 (Unreleased) + + * xD: implemented WALLOPS, choosing to make it target even non-operators + + * xC: made it show WALLOPS messages, as PRIVMSG for the server buffer + + * xC: all behaviour.* configuration options have been renamed to general.*, + with the exception of editor_command/editor, backlog_helper/pager, + and backlog_helper_strip_formatting/pager_strip_formatting + + * xC: all attributes.* configuration options have been made abstract in + a subset of the git-config(1) format, and renamed to theme.*, + with the exception of attributes.reset, which has no replacement + + * xC: replaced behaviour.save_on_quit with general.autosave + + * xC: improved pager integration capabilities + + * xC: unsolicited JOINs will no longer automatically activate the buffer + + * xC: made Readline insert the longest common completion prefix first, + and prevented the possible-completions command from duplicating the prompt + + * xC: normalized editline's history behaviour, making it a viable frontend + + * xC: various bugfixes + + * xC: added a relay interface, enabled through the general.relay_bind option + + * Added a web frontend for xC called xP + + +1.5.0 (2021-12-21) "The Show Must Go On" + + * xC: made it possible to pass the cursor position to external editors, + in particular VIM and Emacs + + * xC: started quoting text coming from bracketed pastes, + to minimize the risk of trying to execute filesystem paths as commands + + * xC: fixed to work with post-2021-08-29 editline + + * xC: extended editline's autocomplete to show all options + + * utm-filter.lua: added Facebook's tracking parameter to the filter + + +1.4.0 (2021-10-06) "Call Me Scruffy Scruffington" + + * xC: made message autosplitting respect text formatting + + * xC: fixed displaying IRC colours above 16 + + * xC: offer IRCnet as an IRC network to connect to, + rather than the lunatic new Freenode + + * xD: started bumping the soft limit on file descriptors to the hard one + + +1.3.0 (2021-08-07) "New World Order" + + * xC: made nick autocompletion offer recent speakers first + + * All binaries have been renamed to something even sillier, + and all references in the source tree have been redacted; + this represents a major incompatible change for all plugins; + configuration and program data have to be adjusted manually + + +1.2.0 (2021-07-08) "There Are Other Countries As Well" + + * xC: added a /squery command for IRCnet + + * xC: added trivial support for SASL EXTERNAL, enabled by adding "sasl" + to the respective server's "capabilities" list + + * xC: now supporting IRCv3.2 capability negotiation, including CAP DEL + + * xC: added support for IRCv3 chghost + + * xC: /deop and /devoice without arguments will use the client's user + + * xC: /set +=/-= now treats its argument as a string array + + * xC: made "/help /command" work the same way as "/help command" does + + * xC: /ban and /unban don't mangle extended bans anymore + + * xC: joining new channels no longer switches to their buffer automatically + if the current input line isn't empty + + * censor.lua: now stripping colours from censored messages; + their attributes are also configurable rather than always black on black + + +1.1.0 (2020-10-31) "What Do You Mean By 'This Isn't Germany'?" + + * xC: made fancy-prompt.lua work with libedit + + * xD: fixed a regression with an unspecified "bind_host" + + * Miscellaneous minor improvements + + +1.0.0 (2020-10-29) "We're Finally There!" + + * Coming with real manual pages instead of help2man-generated stubs + + * xC: added support for more IRC colours and strike-through text (M-m x) + + * xC: now tolerating all UTF-8 messages cut off by the server + + * xC: disabled "behaviour.backlog_helper_strip_formatting" by default + since the relevant issue with ACS terminfo entries has been resolved + + * xC: enabled word wrapping in the backlog by default + + * xC: made the unread marker span the whole line, with a configurable + character; the previous behaviour can be obtained by setting it empty + + * xC: fixed the prompt not showing back up after exiting a backlog helper + when an external event has provoked an attempt to change it + + * xC: now watching fellow channel users' away status when the server + supports the away-notify capability; indicated by italicised nicknames + + * xC: added a plugin to highlight prime numbers in incoming messages + + * xD: make sure an unspecified "bind_host" binds to both IPv4 and IPv6 + + * xB: install plugins to /usr/share and look for them in XDG data dirs + + * Miscellaneous little fixes + + +0.9.8 (2020-09-02) "Yep, Still Using It" + + * xC: fixed a crash and prompt attribute output in libedit 20191231-3.1, + though users are officially discouraged from using this library + + * xC: fixed Lua 5.4 build, so far the support is experimental + + * Miscellaneous little fixes + + +0.9.7 (2018-10-21) "Business as Usual" + + * xD: fix wildcard handling in WHOIS + + * xD: properly handle STATS without parametetrs + + * xD: abort earlier when an invalid mode character is detected while + processing channel MODE messages + + * xD: do not send NICK notifications when the nickname doesn't really change + + * xD: fix hostname string verification (only used for "server_name") + + +0.9.6 (2018-06-22) "I've Been Sitting Here All This Time" + + * Code has been relicensed to 0BSD and moved to a private git hosting + + * Fix LibreSSL compatibility + + * xC: a second /disconnect cuts the connection by force + + * xC: send a QUIT message to the IRC server on Ctrl-C + + * xC: add a Slack plugin (even though the gateway's now defunct) + + * xC: show an error message on log write failure + + * xC: fix parsing of literal IPv6 addresses with port numbers + + * xC: fix some error messages + + * xC: workaround a Readline bug in the fancy-prompt.lua plugin + + * xD: fix two memory leaks + + * xD: improve error handling for incoming connections + + * xD: disable TLS session reuse + + +0.9.5 (2016-12-30) "It's Time" + + * Better support for the KILL command + + * xC: export many more fields to the Lua API, add a prompt hook + + * xC: show channel user count in the prompt + + * xC: allow hiding join/part messages and other noise (Meta-Shift-H) + + * xC: allow autojoining channels with keys + + * xC: rejoin channels with keys on reconnect + + * xC: make /query without arguments just open the buffer + + * xC: add a censor plugin + + * xC: die on configuration parse errors + + * xC: request channel modes also on rejoin + + * xC: don't show remembered channel modes on parted channels + + * xC: fix highlight detection in colored text + + * xC: fix CTCP handling for the real world and don't decode X-QUOTEs + + * xC: add support for OpenSSL 1.1.0 + + +0.9.4 (2016-04-28) "Oops" + + * xC: fix crash on characters invalid in Windows-1252 + + * xC: add an auto-rejoin plugin + + * xC: better date change messages with customizable formatting; + now also used in the backlog, so it looks closer to regular output + + * xB: add a calc plugin providing a basic Scheme REPL + + * xB: add a seen plugin + + * xD, xB: use pledge(2) on OpenBSD + + +0.9.3 (2016-03-27) "Doesn't Even Suck" + + * Use TLS Server Name Indication when connecting to servers + + * xC: now we erase the screen before displaying buffers + + * xC: implemented word wrapping in buffers + + * xC: added autocomplete for /topic + + * xC: Lua API was improved and extended + + * xC: added a basic last.fm "now playing" plugin + + * xC: backlog limit was made configurable + + * xC: allow changing the list of IRC capabilities to use if available + + * xC: optimize buffer memory usage + + * xC: added logging of messages sent from /quote and plugins + + * xC: M-! and M-a to go to the next buffer in order with a highlight + or new activity respectively + + * xC: added --format for previewing things like MOTD files + + * xC: added /buffer goto supporting case insensitive partial matches + + * xD: add support for IRCv3.2 server-time + + * xB: plugins now run in a dedicated data directory + + * xB: added a factoids plugin + + * Remote addresses are now resolved asynchronously + + * Various bugfixes + + +0.9.2 (2015-12-31) + + * xC: added rudimentary support for Lua scripting + + * xC: added detection of pasting, so that it doesn't trigger other + keyboard shortcuts, such as for autocomplete + + * xC: added auto-away capability + + * xC: added an /oper command + + * xC: libedit backend works again + + * xC: added capability to edit the input line using VISUAL/EDITOR + + * xC: added Meta-Tab to switch to the last used buffer + + * xC: correctly respond to stopping and resuming (SIGTSTP) + + * xC: fixed decoding of text formatting + + * xC: unseen PMs now show up as highlights + + * xC: various bugfixes + + +0.9.1 (2015-09-25) + + * All "ssl" options have been renamed to "tls" + + * The project now builds on OpenBSD + + * Pulled in kqueue support + + * xC: added backlog/scrollback functionality using less(1) + + * xC: made showing the entire set of channel mode user prefixes optional + + * xC: nicknames in /names are now ordered + + * xC: nicknames now use the 256-color terminal palette if available + + * xC: now we skip entries in the "addresses" list that can't be resolved + to an address, along with displaying a more helpful message + + * xC: joins, parts, nick changes and quits don't count as new buffer + activity anymore + + * xC: added Meta-H to open the full log file + + * xC: various bugfixes and little improvements + + +0.9.0 (2015-07-23) + + * Initial release + diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..3c09ba7 --- /dev/null +++ b/README.adoc @@ -0,0 +1,210 @@ +xK +== + +'xK' (chat kit) is an IRC software suite consisting of a daemon, bot, terminal +client, and a web frontend for the client. It's all you're ever going to +need for chatting, so long as you can make do with slightly minimalist software. + +They're all lean on dependencies, and offer a maximally permissive licence. + +xC +-- +The IRC client, and the core of 'xK'. It is largely defined by building on top +of GNU Readline or BSD Editline that have been hacked to death. Its interface +should feel somewhat familiar for weechat or irssi users. + +image::xC.webp[align="center"] + +It has most features you'd expect of an IRC client, such as being multiserver, +a powerful configuration system, integrated help, text formatting, automatic +message splitting, multiline editing, bracketed paste support, word wrapping +that doesn't break links, autocomplete, logging, CTCP queries, auto-away, +command aliases, SOCKS proxying, SASL EXTERNAL authentication using TLS client +certificates, a remote relay interface, or basic support for Lua scripting. +As a unique bonus, you can launch a full text editor from within. + +xP +-- +The web frontend for 'xC', making use of its networked relay interface. +It intentionally differs in that it uses a sans-serif font, and it shows +the list of all buffers in a side panel. Otherwise it is a near replica, +including link:xC.adoc#_key_bindings[keyboard shortcuts]. + +image::xP.webp[align="center"] + +xD +-- +The IRC daemon. It is designed for use as a regular user application rather +than a system-wide daemon, and follows the XDG Base Directory Specification. +If all you want is a decent, minimal IRCd for testing purposes or a small +network of respectful users (or bots), this one will do it just fine. + +It autodetects TLS on incoming connections (I'm still wondering why everyone +doesn't have this), authenticates operators via TLS client certificate +fingerprints, and supports a number of IRCv3 capabilities. + +What it notably doesn't support is online changes to configuration, any limits +besides the total number of connections and mode `+l`, or server linking +(which also means no services). + +This program has been https://git.janouch.name/p/haven/src/branch/master/hid[ +ported to Go] in a different project, and development continues over there. + +xB +-- +The IRC bot. While originally intended to be a simple rewrite of my old GNU AWK +bot in C, it fairly quickly became a playground, and it eventually got me into +writing the rest of this package. + +Its main characteristic is that it runs plugins as coprocesses, allowing for +enhanced reliability and programming language freedom. Moreover, it recovers +from any crashes, and offers native SOCKS support (even though socksify can add +that easily to any program). + +Packages +-------- +Regular releases are sporadic. git master should be stable enough. You can get +a package with the latest development version from Archlinux's AUR. + +Building +-------- +Build-only dependencies: CMake, pkg-config, awk, liberty (included), + asciidoctor or asciidoc (recommended but optional) + +Common runtime dependencies: openssl + +Additionally for 'xC': curses, libffi, readline >= 6.0 or libedit >= 2013-07-12, + lua >= 5.3 (optional) + + + $ git clone --recursive https://git.janouch.name/p/xK.git + $ mkdir xK/build + $ cd xK/build + $ cmake .. -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DWANT_READLINE=ON -DWANT_LIBEDIT=OFF -DWITH_LUA=ON + $ make + +To install the application, you can do either the usual: + + # make install + +Or you can try telling CMake to make a package for you: + + $ cpack -G DEB # also supported: RPM, FreeBSD + # dpkg -i xK-*.deb + +Usage +----- +'xC' has in-program configuration. Just run it and read the instructions. +Consult its link:xC.adoc[man page] for details about the interface. + +For the rest you might want to generate a configuration file: + + $ xB --write-default-config + $ xD --write-default-config + +After making any necessary edits to the file (there are comments to aid you in +doing that), simply run the appropriate program with no arguments: + + $ xB + $ xD + +'xB' stays running in the foreground, therefore I recommend launching it inside +a Screen or tmux session. + +'xD', on the other hand, immediately forks into the background. Use the PID +file or something like `killall` if you want to terminate it. You can run it +as a `forking` type systemd user service. + +xP +~~ +The precondition for running 'xC' frontends is enabling its relay interface: + + /set general.relay_bind = "127.0.0.1:9000" + +To build the web server, you'll need to install the Go compiler, and run `make` +from the _xP_ directory. Then start it from the _public_ subdirectory, +and navigate to the adress you gave it as its first argument--in the following +example, that would be http://localhost:8080[]: + + $ ../xP 127.0.0.1:8080 127.0.0.1:9000 + +For remote use, it's recommended to put 'xP' behind a reverse proxy, with TLS, +and some form of HTTP authentication. Pass the external URL of the WebSocket +endpoint as the third command line argument in this case. + +Client Certificates +------------------- +'xC' will use the SASL EXTERNAL method to authenticate using the TLS client +certificate specified by the respective server's `tls_cert` option if you add +`sasl` to the `capabilities` option and the server supports this. + +'xD' uses SHA-1 fingerprints of TLS client certificates to authenticate users. +To get the fingerprint from a certificate file in the required form, use: + + $ openssl x509 -in public.pem -outform DER | sha1sum + +Custom Key Bindings in xC +------------------------- +The default and preferred frontend used in 'xC' is GNU Readline. This means +that you can change your bindings by editing '~/.inputrc'. For example: + +.... +# Preload with system-wide settings +$include /etc/inputrc + +# Make M-left and M-right reorder buffers +$if xC +"\e\e[C": move-buffer-right +"\e\e[D": move-buffer-left +$endif +.... + +Consult the source code and the GNU Readline manual for a list of available +functions. Also refer to the latter for the exact syntax of this file. +Beware that you can easily break the program if you're not careful. + +How do I make xC look like the screenshot? +------------------------------------------ +With the defaults, 'xC' doesn't look too fancy because I don't want to have +a hard dependency on either Lua for the bundled script that provides an easily +adjustable enhanced prompt, or on 256-colour terminals. Moreover, it's nearly +impossible to come up with a colour theme that would work well with both +black-on-white and white-on-black terminals, or anything wild in between. + +Assuming that your build supports Lua plugins, and that you have a decent, +properly set-up terminal emulator, it suffices to run: + + /set general.pager = Press Tab here and change +Gb to +Gb1d + /set general.date_change_line = "%a %e %b %Y" + /set general.plugin_autoload += "fancy-prompt.lua" + /set theme.userhost = "109" + /set theme.join = "108" + /set theme.part = "138" + /set theme.external = "248" + /set theme.timestamp = "250 255" + /set theme.read_marker = "202" + +Configuration profiles +---------------------- +Even though the applications don't directly support configuration profiles, +they conform to the XDG standard, and thus you can change the location they +load configuration from via XDG_CONFIG_HOME (normally '~/.config') and the +location where store their data via XDG_DATA_HOME (normally '~/.local/share'). + +It would be relatively easy to make the applications assume whatever name you +run them under (for example by using symbolic links), and load different +configurations accordingly, but I consider it rather messy and unnecessary. + +Contributing and Support +------------------------ +Use https://git.janouch.name/p/xK to report any bugs, request features, +or submit pull requests. `git send-email` is tolerated. If you want to discuss +the project, feel free to join me at ircs://irc.janouch.name, channel #dev. + +Bitcoin donations are accepted at: 12r5uEWEgcHC46xd64tt3hHt9EUvYYDHe9 + +License +------- +This software is released under the terms of the 0BSD license, the text of which +is included within the package along with the list of authors. + +Note that 'xC' becomes GPL-licensed when you link it against GNU Readline, +but that is not a concern of this source package. The licenses are compatible. diff --git a/common.c b/common.c new file mode 100644 index 0000000..ac83776 --- /dev/null +++ b/common.c @@ -0,0 +1,1096 @@ +/* + * common.c: common functionality + * + * Copyright (c) 2014 - 2022, Přemysl Eric Janouch <p@janouch.name> + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#define LIBERTY_WANT_SSL +#define LIBERTY_WANT_ASYNC +#define LIBERTY_WANT_POLLER +#define LIBERTY_WANT_PROTO_IRC + +#ifdef WANT_SYSLOG_LOGGING +#define print_fatal_data ((void *) LOG_ERR) +#define print_error_data ((void *) LOG_ERR) +#define print_warning_data ((void *) LOG_WARNING) +#define print_status_data ((void *) LOG_INFO) +#define print_debug_data ((void *) LOG_DEBUG) +#endif // WANT_SYSLOG_LOGGING + +#include "liberty/liberty.c" +#include <arpa/inet.h> +#include <netinet/tcp.h> + +static void +init_openssl (void) +{ +#if OPENSSL_VERSION_NUMBER < 0x10100000L || LIBRESSL_VERSION_NUMBER + SSL_library_init (); + // XXX: this list is probably not complete + atexit (EVP_cleanup); + SSL_load_error_strings (); + atexit (ERR_free_strings); +#else + // Cleanup is done automatically via atexit() + OPENSSL_init_ssl (0, NULL); +#endif +} + +static char * +gai_reconstruct_address (struct addrinfo *ai) +{ + char host[NI_MAXHOST] = {}, port[NI_MAXSERV] = {}; + int err = getnameinfo (ai->ai_addr, ai->ai_addrlen, + host, sizeof host, port, sizeof port, + NI_NUMERICHOST | NI_NUMERICSERV); + if (err) + { + print_debug ("%s: %s", "getnameinfo", gai_strerror (err)); + return xstrdup ("?"); + } + return format_host_port_pair (host, port); +} + +static bool +accept_error_is_transient (int err) +{ + // OS kernels may return a wide range of unforeseeable errors. + // Assuming that they're either transient or caused by + // a connection that we've just extracted from the queue. + switch (err) + { + case EBADF: + case EINVAL: + case ENOTSOCK: + case EOPNOTSUPP: + return false; + default: + return true; + } +} + +/// Destructively tokenize an address into a host part, and a port part. +/// The port is only overwritten if that part is found, allowing for defaults. +static const char * +tokenize_host_port (char *address, const char **port) +{ + // Unwrap IPv6 addresses in format_host_port_pair() format. + char *rbracket = strchr (address, ']'); + if (*address == '[' && rbracket) + { + if (rbracket[1] == ':') + { + *port = rbracket + 2; + return *rbracket = 0, address + 1; + } + if (!rbracket[1]) + return *rbracket = 0, address + 1; + } + + char *colon = strchr (address, ':'); + if (colon) + { + *port = colon + 1; + return *colon = 0, address; + } + return address; +} + +// --- To be moved to liberty -------------------------------------------------- + +// FIXME: in xssl_get_error() we rely on error reasons never being NULL (i.e., +// all loaded), which isn't very robust. +// TODO: check all places where this is used and see if we couldn't gain better +// information by piecing together some other subset of data from the error +// stack. Most often, this is used in an error_set() context, which would +// allow us to allocate memory instead of returning static strings. +static const char * +xerr_describe_error (void) +{ + unsigned long err = ERR_get_error (); + if (!err) + return "undefined error"; + + const char *reason = ERR_reason_error_string (err); + do + // Not thread-safe, not a concern right now--need a buffer + print_debug ("%s", ERR_error_string (err, NULL)); + while ((err = ERR_get_error ())); + + if (!reason) + return "cannot retrieve error description"; + return reason; +} + +static struct str +str_from_cstr (const char *cstr) +{ + struct str self; + self.alloc = (self.len = strlen (cstr)) + 1; + self.str = memcpy (xmalloc (self.alloc), cstr, self.alloc); + return self; +} + +static ssize_t +strv_find (const struct strv *v, const char *s) +{ + for (size_t i = 0; i < v->len; i++) + if (!strcmp (v->vector[i], s)) + return i; + return -1; +} + +static time_t +unixtime_msec (long *msec) +{ +#ifdef _POSIX_TIMERS + struct timespec tp; + hard_assert (clock_gettime (CLOCK_REALTIME, &tp) != -1); + *msec = tp.tv_nsec / 1000000; +#else // ! _POSIX_TIMERS + struct timeval tp; + hard_assert (gettimeofday (&tp, NULL) != -1); + *msec = tp.tv_usec / 1000; +#endif // ! _POSIX_TIMERS + return tp.tv_sec; +} + +// --- Logging ----------------------------------------------------------------- + +static void +log_message_syslog (void *user_data, const char *quote, const char *fmt, + va_list ap) +{ + int prio = (int) (intptr_t) user_data; + + va_list va; + va_copy (va, ap); + int size = vsnprintf (NULL, 0, fmt, va); + va_end (va); + if (size < 0) + return; + + char buf[size + 1]; + if (vsnprintf (buf, sizeof buf, fmt, ap) >= 0) + syslog (prio, "%s%s", quote, buf); +} + +// --- SOCKS 5/4a -------------------------------------------------------------- + +// Asynchronous SOCKS connector. Adds more stuff on top of the regular one. + +// Note that the `username' is used differently in SOCKS 4a and 5. In the +// former version, it is the username that you can get ident'ed against. +// In the latter version, it forms a pair with the password field and doesn't +// need to be an actual user on your machine. + +struct socks_addr +{ + enum socks_addr_type + { + SOCKS_IPV4 = 1, ///< IPv4 address + SOCKS_DOMAIN = 3, ///< Domain name to be resolved + SOCKS_IPV6 = 4 ///< IPv6 address + } + type; ///< The type of this address + union + { + uint8_t ipv4[4]; ///< IPv4 address, network octet order + char *domain; ///< Domain name + uint8_t ipv6[16]; ///< IPv6 address, network octet order + } + data; ///< The address itself +}; + +static void +socks_addr_free (struct socks_addr *self) +{ + if (self->type == SOCKS_DOMAIN) + free (self->data.domain); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct socks_target +{ + LIST_HEADER (struct socks_target) + + char *address_str; ///< Target address as a string + struct socks_addr address; ///< Target address + uint16_t port; ///< Target service port +}; + +enum socks_protocol +{ + SOCKS_5, ///< SOCKS5 + SOCKS_4A, ///< SOCKS4A + SOCKS_MAX ///< End of protocol +}; + +static inline const char * +socks_protocol_to_string (enum socks_protocol self) +{ + switch (self) + { + case SOCKS_5: return "SOCKS5"; + case SOCKS_4A: return "SOCKS4A"; + default: return NULL; + } +} + +struct socks_connector +{ + struct connector *connector; ///< Proxy server iterator (effectively) + enum socks_protocol protocol_iter; ///< Protocol iterator + struct socks_target *targets_iter; ///< Targets iterator + + // Negotiation: + + struct poller_timer timeout; ///< Timeout timer + + int socket_fd; ///< Current socket file descriptor + struct poller_fd socket_event; ///< Socket can be read from/written to + struct str read_buffer; ///< Read buffer + struct str write_buffer; ///< Write buffer + + bool done; ///< Tunnel succesfully established + uint8_t bound_address_len; ///< Length of domain name + size_t data_needed; ///< How much data "on_data" needs + + /// Process incoming data if there's enough of it available + bool (*on_data) (struct socks_connector *, struct msg_unpacker *); + + // Configuration: + + char *hostname; ///< SOCKS server hostname + char *service; ///< SOCKS server service name or port + + char *username; ///< Username for authentication + char *password; ///< Password for authentication + + struct socks_target *targets; ///< Targets + struct socks_target *targets_tail; ///< Tail of targets + + void *user_data; ///< User data for callbacks + + // Additional results: + + struct socks_addr bound_address; ///< Bound address at the server + uint16_t bound_port; ///< Bound port at the server + + // You may destroy the connector object in these two main callbacks: + + /// Connection has been successfully established + void (*on_connected) (void *user_data, int socket, const char *hostname); + /// Failed to establish a connection to either target + void (*on_failure) (void *user_data); + + // Optional: + + /// Connecting to a new address + void (*on_connecting) (void *user_data, + const char *address, const char *via, const char *version); + /// Connecting to the last address has failed + void (*on_error) (void *user_data, const char *error); +}; + +// I've tried to make the actual protocol handlers as simple as possible + +#define SOCKS_FAIL(...) \ + BLOCK_START \ + char *error = xstrdup_printf (__VA_ARGS__); \ + if (self->on_error) \ + self->on_error (self->user_data, error); \ + free (error); \ + return false; \ + BLOCK_END + +#define SOCKS_DATA_CB(name) static bool name \ + (struct socks_connector *self, struct msg_unpacker *unpacker) + +#define SOCKS_GO(name, data_needed_) \ + self->on_data = name; \ + self->data_needed = data_needed_; \ + return true + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +SOCKS_DATA_CB (socks_4a_finish) +{ + uint8_t null = 0, status = 0; + hard_assert (msg_unpacker_u8 (unpacker, &null)); + hard_assert (msg_unpacker_u8 (unpacker, &status)); + + if (null != 0) + SOCKS_FAIL ("protocol error"); + + switch (status) + { + case 90: + self->done = true; + return false; + case 91: + SOCKS_FAIL ("request rejected or failed"); + case 92: + SOCKS_FAIL ("%s: %s", "request rejected", + "SOCKS server cannot connect to identd on the client"); + case 93: + SOCKS_FAIL ("%s: %s", "request rejected", + "identd reports different user-id"); + default: + SOCKS_FAIL ("protocol error"); + } +} + +static bool +socks_4a_start (struct socks_connector *self) +{ + struct socks_target *target = self->targets_iter; + const void *dest_ipv4 = "\x00\x00\x00\x01"; + const char *dest_domain = NULL; + + char buf[INET6_ADDRSTRLEN]; + switch (target->address.type) + { + case SOCKS_IPV4: + dest_ipv4 = target->address.data.ipv4; + break; + case SOCKS_IPV6: + // About the best thing we can do, not sure if it works anywhere at all + if (!inet_ntop (AF_INET6, &target->address.data.ipv6, buf, sizeof buf)) + SOCKS_FAIL ("%s: %s", "inet_ntop", strerror (errno)); + dest_domain = buf; + break; + case SOCKS_DOMAIN: + dest_domain = target->address.data.domain; + } + + struct str *wb = &self->write_buffer; + str_pack_u8 (wb, 4); // version + str_pack_u8 (wb, 1); // connect + + str_pack_u16 (wb, target->port); // port + str_append_data (wb, dest_ipv4, 4); // destination address + + if (self->username) + str_append (wb, self->username); + str_append_c (wb, '\0'); + + if (dest_domain) + { + str_append (wb, dest_domain); + str_append_c (wb, '\0'); + } + + SOCKS_GO (socks_4a_finish, 8); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +SOCKS_DATA_CB (socks_5_request_port) +{ + hard_assert (msg_unpacker_u16 (unpacker, &self->bound_port)); + self->done = true; + return false; +} + +SOCKS_DATA_CB (socks_5_request_ipv4) +{ + memcpy (self->bound_address.data.ipv4, unpacker->data, unpacker->len); + SOCKS_GO (socks_5_request_port, 2); +} + +SOCKS_DATA_CB (socks_5_request_ipv6) +{ + memcpy (self->bound_address.data.ipv6, unpacker->data, unpacker->len); + SOCKS_GO (socks_5_request_port, 2); +} + +SOCKS_DATA_CB (socks_5_request_domain_data) +{ + self->bound_address.data.domain = xstrndup (unpacker->data, unpacker->len); + SOCKS_GO (socks_5_request_port, 2); +} + +SOCKS_DATA_CB (socks_5_request_domain) +{ + hard_assert (msg_unpacker_u8 (unpacker, &self->bound_address_len)); + SOCKS_GO (socks_5_request_domain_data, self->bound_address_len); +} + +SOCKS_DATA_CB (socks_5_request_finish) +{ + uint8_t version = 0, status = 0, reserved = 0, type = 0; + hard_assert (msg_unpacker_u8 (unpacker, &version)); + hard_assert (msg_unpacker_u8 (unpacker, &status)); + hard_assert (msg_unpacker_u8 (unpacker, &reserved)); + hard_assert (msg_unpacker_u8 (unpacker, &type)); + + if (version != 0x05) + SOCKS_FAIL ("protocol error"); + + switch (status) + { + case 0x00: + break; + case 0x01: SOCKS_FAIL ("general SOCKS server failure"); + case 0x02: SOCKS_FAIL ("connection not allowed by ruleset"); + case 0x03: SOCKS_FAIL ("network unreachable"); + case 0x04: SOCKS_FAIL ("host unreachable"); + case 0x05: SOCKS_FAIL ("connection refused"); + case 0x06: SOCKS_FAIL ("TTL expired"); + case 0x07: SOCKS_FAIL ("command not supported"); + case 0x08: SOCKS_FAIL ("address type not supported"); + default: SOCKS_FAIL ("protocol error"); + } + + switch ((self->bound_address.type = type)) + { + case SOCKS_IPV4: + SOCKS_GO (socks_5_request_ipv4, sizeof self->bound_address.data.ipv4); + case SOCKS_IPV6: + SOCKS_GO (socks_5_request_ipv6, sizeof self->bound_address.data.ipv6); + case SOCKS_DOMAIN: + SOCKS_GO (socks_5_request_domain, 1); + default: + SOCKS_FAIL ("protocol error"); + } +} + +static bool +socks_5_request_start (struct socks_connector *self) +{ + struct socks_target *target = self->targets_iter; + struct str *wb = &self->write_buffer; + str_pack_u8 (wb, 0x05); // version + str_pack_u8 (wb, 0x01); // connect + str_pack_u8 (wb, 0x00); // reserved + str_pack_u8 (wb, target->address.type); + + switch (target->address.type) + { + case SOCKS_IPV4: + str_append_data (wb, + target->address.data.ipv4, sizeof target->address.data.ipv4); + break; + case SOCKS_DOMAIN: + { + size_t dlen = strlen (target->address.data.domain); + if (dlen > 255) + dlen = 255; + + str_pack_u8 (wb, dlen); + str_append_data (wb, target->address.data.domain, dlen); + break; + } + case SOCKS_IPV6: + str_append_data (wb, + target->address.data.ipv6, sizeof target->address.data.ipv6); + break; + } + str_pack_u16 (wb, target->port); + + SOCKS_GO (socks_5_request_finish, 4); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +SOCKS_DATA_CB (socks_5_userpass_finish) +{ + uint8_t version = 0, status = 0; + hard_assert (msg_unpacker_u8 (unpacker, &version)); + hard_assert (msg_unpacker_u8 (unpacker, &status)); + + if (version != 0x01) + SOCKS_FAIL ("protocol error"); + if (status != 0x00) + SOCKS_FAIL ("authentication failure"); + + return socks_5_request_start (self); +} + +static bool +socks_5_userpass_start (struct socks_connector *self) +{ + size_t ulen = strlen (self->username); + if (ulen > 255) + ulen = 255; + + size_t plen = strlen (self->password); + if (plen > 255) + plen = 255; + + struct str *wb = &self->write_buffer; + str_pack_u8 (wb, 0x01); // version + str_pack_u8 (wb, ulen); // username length + str_append_data (wb, self->username, ulen); + str_pack_u8 (wb, plen); // password length + str_append_data (wb, self->password, plen); + + SOCKS_GO (socks_5_userpass_finish, 2); +} + +SOCKS_DATA_CB (socks_5_auth_finish) +{ + uint8_t version = 0, method = 0; + hard_assert (msg_unpacker_u8 (unpacker, &version)); + hard_assert (msg_unpacker_u8 (unpacker, &method)); + + if (version != 0x05) + SOCKS_FAIL ("protocol error"); + + bool can_auth = self->username && self->password; + + switch (method) + { + case 0x02: + if (!can_auth) + SOCKS_FAIL ("protocol error"); + + return socks_5_userpass_start (self); + case 0x00: + return socks_5_request_start (self); + case 0xFF: + SOCKS_FAIL ("no acceptable authentication methods"); + default: + SOCKS_FAIL ("protocol error"); + } +} + +static bool +socks_5_auth_start (struct socks_connector *self) +{ + bool can_auth = self->username && self->password; + + struct str *wb = &self->write_buffer; + str_pack_u8 (wb, 0x05); // version + str_pack_u8 (wb, 1 + can_auth); // number of authentication methods + str_pack_u8 (wb, 0x00); // no authentication required + if (can_auth) + str_pack_u8 (wb, 0x02); // username/password + + SOCKS_GO (socks_5_auth_finish, 2); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void socks_connector_start (struct socks_connector *self); + +static void +socks_connector_destroy_connector (struct socks_connector *self) +{ + if (self->connector) + { + connector_free (self->connector); + free (self->connector); + self->connector = NULL; + } +} + +static void +socks_connector_cancel_events (struct socks_connector *self) +{ + // Before calling the final callbacks, we should cancel events that + // could potentially fire; caller should destroy us immediately, though + poller_fd_reset (&self->socket_event); + poller_timer_reset (&self->timeout); +} + +static void +socks_connector_fail (struct socks_connector *self) +{ + socks_connector_cancel_events (self); + self->on_failure (self->user_data); +} + +static bool +socks_connector_step_iterators (struct socks_connector *self) +{ + // At the lowest level we iterate over all addresses for the SOCKS server + // and just try to connect; this is done automatically by the connector + + // Then we iterate over available protocols + if (++self->protocol_iter != SOCKS_MAX) + return true; + + // At the highest level we iterate over possible targets + self->protocol_iter = 0; + if (self->targets_iter && (self->targets_iter = self->targets_iter->next)) + return true; + + return false; +} + +static void +socks_connector_step (struct socks_connector *self) +{ + if (self->socket_fd != -1) + { + poller_fd_reset (&self->socket_event); + xclose (self->socket_fd); + self->socket_fd = -1; + } + + socks_connector_destroy_connector (self); + if (socks_connector_step_iterators (self)) + socks_connector_start (self); + else + socks_connector_fail (self); +} + +static void +socks_connector_on_timeout (struct socks_connector *self) +{ + if (self->on_error) + self->on_error (self->user_data, "timeout"); + + socks_connector_destroy_connector (self); + socks_connector_fail (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +socks_connector_on_connected + (void *user_data, int socket_fd, const char *hostname) +{ + set_blocking (socket_fd, false); + (void) hostname; + + struct socks_connector *self = user_data; + self->socket_fd = socket_fd; + self->socket_event.fd = socket_fd; + poller_fd_set (&self->socket_event, POLLIN | POLLOUT); + str_reset (&self->read_buffer); + str_reset (&self->write_buffer); + + if (!(self->protocol_iter == SOCKS_5 && socks_5_auth_start (self)) + && !(self->protocol_iter == SOCKS_4A && socks_4a_start (self))) + socks_connector_fail (self); +} + +static void +socks_connector_on_failure (void *user_data) +{ + struct socks_connector *self = user_data; + // TODO: skip SOCKS server on connection failure + socks_connector_step (self); +} + +static void +socks_connector_on_connecting (void *user_data, const char *via) +{ + struct socks_connector *self = user_data; + if (!self->on_connecting) + return; + + struct socks_target *target = self->targets_iter; + char *port = xstrdup_printf ("%u", target->port); + char *address = format_host_port_pair (target->address_str, port); + free (port); + self->on_connecting (self->user_data, address, via, + socks_protocol_to_string (self->protocol_iter)); + free (address); +} + +static void +socks_connector_on_error (void *user_data, const char *error) +{ + struct socks_connector *self = user_data; + // TODO: skip protocol on protocol failure + if (self->on_error) + self->on_error (self->user_data, error); +} + +static void +socks_connector_start (struct socks_connector *self) +{ + hard_assert (!self->connector); + + struct connector *connector = + self->connector = xcalloc (1, sizeof *connector); + connector_init (connector, self->socket_event.poller); + + connector->user_data = self; + connector->on_connected = socks_connector_on_connected; + connector->on_connecting = socks_connector_on_connecting; + connector->on_error = socks_connector_on_error; + connector->on_failure = socks_connector_on_failure; + + connector_add_target (connector, self->hostname, self->service); + poller_timer_set (&self->timeout, 60 * 1000); + self->done = false; + + self->bound_port = 0; + socks_addr_free (&self->bound_address); + memset (&self->bound_address, 0, sizeof self->bound_address); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +socks_try_fill_read_buffer (struct socks_connector *self, size_t n) +{ + ssize_t remains = (ssize_t) n - (ssize_t) self->read_buffer.len; + if (remains <= 0) + return true; + + ssize_t received; + str_reserve (&self->read_buffer, remains); + do + received = recv (self->socket_fd, + self->read_buffer.str + self->read_buffer.len, remains, 0); + while ((received == -1) && errno == EINTR); + + if (received == 0) + SOCKS_FAIL ("%s: %s", "protocol error", "unexpected EOF"); + if (received == -1 && errno != EAGAIN) + SOCKS_FAIL ("%s: %s", "recv", strerror (errno)); + if (received > 0) + self->read_buffer.len += received; + return true; +} + +static bool +socks_call_on_data (struct socks_connector *self) +{ + size_t to_consume = self->data_needed; + if (!socks_try_fill_read_buffer (self, to_consume)) + return false; + if (self->read_buffer.len < to_consume) + return true; + + struct msg_unpacker unpacker = + msg_unpacker_make (self->read_buffer.str, self->read_buffer.len); + bool result = self->on_data (self, &unpacker); + str_remove_slice (&self->read_buffer, 0, to_consume); + return result; +} + +static bool +socks_try_flush_write_buffer (struct socks_connector *self) +{ + struct str *wb = &self->write_buffer; + ssize_t n_written; + + while (wb->len) + { + n_written = send (self->socket_fd, wb->str, wb->len, 0); + if (n_written >= 0) + { + str_remove_slice (wb, 0, n_written); + continue; + } + + if (errno == EAGAIN) + break; + if (errno == EINTR) + continue; + + SOCKS_FAIL ("%s: %s", "send", strerror (errno)); + } + return true; +} + +static void +socks_connector_on_ready + (const struct pollfd *pfd, struct socks_connector *self) +{ + (void) pfd; + + if (socks_call_on_data (self) && socks_try_flush_write_buffer (self)) + { + poller_fd_set (&self->socket_event, + self->write_buffer.len ? (POLLIN | POLLOUT) : POLLIN); + } + else if (self->done) + { + socks_connector_cancel_events (self); + + int fd = self->socket_fd; + self->socket_fd = -1; + + struct socks_target *target = self->targets_iter; + set_blocking (fd, true); + self->on_connected (self->user_data, fd, target->address_str); + } + else + // We've failed this target, let's try to move on + socks_connector_step (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +socks_connector_init (struct socks_connector *self, struct poller *poller) +{ + memset (self, 0, sizeof *self); + + self->socket_event = poller_fd_make (poller, (self->socket_fd = -1)); + self->socket_event.dispatcher = (poller_fd_fn) socks_connector_on_ready; + self->socket_event.user_data = self; + + self->timeout = poller_timer_make (poller); + self->timeout.dispatcher = (poller_timer_fn) socks_connector_on_timeout; + self->timeout.user_data = self; + + self->read_buffer = str_make (); + self->write_buffer = str_make (); +} + +static void +socks_connector_free (struct socks_connector *self) +{ + socks_connector_destroy_connector (self); + socks_connector_cancel_events (self); + + if (self->socket_fd != -1) + xclose (self->socket_fd); + + str_free (&self->read_buffer); + str_free (&self->write_buffer); + + free (self->hostname); + free (self->service); + free (self->username); + free (self->password); + + LIST_FOR_EACH (struct socks_target, iter, self->targets) + { + socks_addr_free (&iter->address); + free (iter->address_str); + free (iter); + } + + socks_addr_free (&self->bound_address); +} + +static bool +socks_connector_add_target (struct socks_connector *self, + const char *host, const char *service, struct error **e) +{ + unsigned long port; + const struct servent *serv; + if ((serv = getservbyname (service, "tcp"))) + port = (uint16_t) ntohs (serv->s_port); + else if (!xstrtoul (&port, service, 10) || !port || port > UINT16_MAX) + { + error_set (e, "invalid port number"); + return false; + } + + struct socks_target *target = xcalloc (1, sizeof *target); + if (inet_pton (AF_INET, host, &target->address.data.ipv4) == 1) + target->address.type = SOCKS_IPV4; + else if (inet_pton (AF_INET6, host, &target->address.data.ipv6) == 1) + target->address.type = SOCKS_IPV6; + else + { + target->address.type = SOCKS_DOMAIN; + target->address.data.domain = xstrdup (host); + } + + target->port = port; + target->address_str = xstrdup (host); + LIST_APPEND_WITH_TAIL (self->targets, self->targets_tail, target); + return true; +} + +static void +socks_connector_run (struct socks_connector *self, + const char *host, const char *service, + const char *username, const char *password) +{ + hard_assert (self->targets); + hard_assert (host && service); + + self->hostname = xstrdup (host); + self->service = xstrdup (service); + + if (username) self->username = xstrdup (username); + if (password) self->password = xstrdup (password); + + self->targets_iter = self->targets; + self->protocol_iter = 0; + // XXX: this can fail immediately from an error creating the connector + socks_connector_start (self); +} + +// --- CTCP decoding ----------------------------------------------------------- + +#define CTCP_M_QUOTE '\020' +#define CTCP_X_DELIM '\001' +#define CTCP_X_QUOTE '\\' + +struct ctcp_chunk +{ + LIST_HEADER (struct ctcp_chunk) + + bool is_extended; ///< Is this a tagged extended message? + bool is_partial; ///< Unterminated extended message + struct str tag; ///< The tag, if any + struct str text; ///< Message contents +}; + +static struct ctcp_chunk * +ctcp_chunk_new (void) +{ + struct ctcp_chunk *self = xcalloc (1, sizeof *self); + self->tag = str_make (); + self->text = str_make (); + return self; +} + +static void +ctcp_chunk_destroy (struct ctcp_chunk *self) +{ + str_free (&self->tag); + str_free (&self->text); + free (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +ctcp_low_level_decode (const char *message, struct str *output) +{ + bool escape = false; + for (const char *p = message; *p; p++) + { + if (escape) + { + switch (*p) + { + case '0': str_append_c (output, '\0'); break; + case 'r': str_append_c (output, '\r'); break; + case 'n': str_append_c (output, '\n'); break; + default: str_append_c (output, *p); + } + escape = false; + } + else if (*p == CTCP_M_QUOTE) + escape = true; + else + str_append_c (output, *p); + } +} + +static void +ctcp_intra_decode (const char *chunk, size_t len, struct str *output) +{ + bool escape = false; + for (size_t i = 0; i < len; i++) + { + char c = chunk[i]; + if (escape) + { + if (c == 'a') + str_append_c (output, CTCP_X_DELIM); + else + str_append_c (output, c); + escape = false; + } + else if (c == CTCP_X_QUOTE) + escape = true; + else + str_append_c (output, c); + } +} + +// According to the original CTCP specification we should use +// ctcp_intra_decode() on all parts, however no one seems to use that +// and it breaks normal text with backslashes +#ifndef SUPPORT_CTCP_X_QUOTES +#define ctcp_intra_decode(s, len, output) str_append_data (output, s, len) +#endif + +static void +ctcp_parse_tagged (const char *chunk, size_t len, struct ctcp_chunk *output) +{ + // We may search for the space before doing the higher level decoding, + // as it doesn't concern space characters at all + size_t tag_end = len; + for (size_t i = 0; i < len; i++) + if (chunk[i] == ' ') + { + tag_end = i; + break; + } + + output->is_extended = true; + ctcp_intra_decode (chunk, tag_end, &output->tag); + if (tag_end++ != len) + ctcp_intra_decode (chunk + tag_end, len - tag_end, &output->text); +} + +static struct ctcp_chunk * +ctcp_parse (const char *message) +{ + struct str m = str_make (); + ctcp_low_level_decode (message, &m); + + struct ctcp_chunk *result = NULL, *result_tail = NULL; + + size_t start = 0; + bool in_ctcp = false; + for (size_t i = 0; i < m.len; i++) + { + char c = m.str[i]; + if (c != CTCP_X_DELIM) + continue; + + // Remember the current state + size_t my_start = start; + bool my_is_ctcp = in_ctcp; + + start = i + 1; + in_ctcp = !in_ctcp; + + // Skip empty chunks + if (my_start == i) + continue; + + struct ctcp_chunk *chunk = ctcp_chunk_new (); + if (my_is_ctcp) + ctcp_parse_tagged (m.str + my_start, i - my_start, chunk); + else + ctcp_intra_decode (m.str + my_start, i - my_start, &chunk->text); + LIST_APPEND_WITH_TAIL (result, result_tail, chunk); + } + + // Finish the last part. Unended tagged chunks are marked as such. + if (start != m.len) + { + struct ctcp_chunk *chunk = ctcp_chunk_new (); + if (in_ctcp) + { + ctcp_parse_tagged (m.str + start, m.len - start, chunk); + chunk->is_partial = true; + } + else + ctcp_intra_decode (m.str + start, m.len - start, &chunk->text); + LIST_APPEND_WITH_TAIL (result, result_tail, chunk); + } + + str_free (&m); + return result; +} + +static void +ctcp_destroy (struct ctcp_chunk *list) +{ + LIST_FOR_EACH (struct ctcp_chunk, iter, list) + ctcp_chunk_destroy (iter); +} diff --git a/config.h.in b/config.h.in new file mode 100644 index 0000000..6f3911f --- /dev/null +++ b/config.h.in @@ -0,0 +1,15 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#define PROGRAM_VERSION "${project_version}" + +// We use the XDG Base Directory Specification, but may be installed anywhere. +#define PROJECT_DATADIR "${CMAKE_INSTALL_FULL_DATADIR}" + +#cmakedefine HAVE_READLINE +#cmakedefine HAVE_EDITLINE +#cmakedefine HAVE_LUA + +#cmakedefine01 ICONV_ACCEPTS_TRANSLIT + +#endif // ! CONFIG_H diff --git a/liberty b/liberty new file mode 160000 +Subproject 34460ca715b295cc73c3f2bba4156c7f072ce12 diff --git a/plugins/xB/calc b/plugins/xB/calc new file mode 100755 index 0000000..7d2d660 --- /dev/null +++ b/plugins/xB/calc @@ -0,0 +1,241 @@ +#!/usr/bin/env guile + + xB calc plugin, basic Scheme evaluator + + Copyright 2016 Přemysl Eric Janouch + See the file LICENSE for licensing information. + +!# + +(import (rnrs (6))) +(use-modules ((rnrs) :version (6))) + +; --- Message parsing ---------------------------------------------------------- + +(define-record-type message (fields prefix command params)) +(define (parse-message line) + (let f ([parts '()] [chars (string->list line)]) + (define (take-word w chars) + (if (or (null? chars) (eqv? (car chars) #\x20)) + (f (cons (list->string (reverse w)) parts) + (if (null? chars) chars (cdr chars))) + (take-word (cons (car chars) w) (cdr chars)))) + (if (null? chars) + (let ([data (reverse parts)]) + (when (< (length data) 2) + (error 'parse-message "invalid message")) + (make-message (car data) (cadr data) (cddr data))) + (if (null? parts) + (if (eqv? (car chars) #\:) + (take-word '() (cdr chars)) + (f (cons #f parts) chars)) + (if (eqv? (car chars) #\:) + (f (cons (list->string (cdr chars)) parts) '()) + (take-word '() chars)))))) + +; --- Utilities ---------------------------------------------------------------- + +(define (display-exception e port) + (define (puts . x) + (for-all (lambda (a) (display a port)) x) + (newline port)) + + (define (record-fields rec) + (let* ([rtd (record-rtd rec)] + [v (record-type-field-names rtd)] + [len (vector-length v)]) + (map (lambda (k i) (cons k ((record-accessor rtd i) rec))) + (vector->list v) + (let c ([i len] [ls '()]) + (if (= i 0) ls (c (- i 1) (cons (- i 1) ls))))))) + + (puts "Caught " (record-type-name (record-rtd e))) + (for-all + (lambda (subtype) + (puts " " (record-type-name (record-rtd subtype))) + (for-all + (lambda (field) (puts " " (car field) ": " (cdr field))) + (record-fields subtype))) + (simple-conditions e))) + +; XXX - we have to work around Guile's lack of proper eol-style support +(define xc (make-transcoder (latin-1-codec) 'lf 'replace)) +(define irc-input-port (transcoded-port (standard-input-port) xc)) +(define irc-output-port (transcoded-port (standard-output-port) xc)) + +(define (send . message) + (for-all (lambda (x) (display x irc-output-port)) message) + (display #\return irc-output-port) + (newline irc-output-port) + (flush-output-port irc-output-port)) + +(define (get-line-crlf port) + (define line (get-line port)) + (if (eof-object? line) line + (let ([len (string-length line)]) + (if (and (> len 0) (eqv? (string-ref line (- len 1)) #\return)) + (substring line 0 (- len 1)) line)))) + +(define (get-config name) + (send "XB get_config :" name) + (car (message-params (parse-message (get-line-crlf irc-input-port))))) + +(define (extract-nick prefix) + (do ([i 0 (+ i 1)] [len (string-length prefix)]) + ([or (= i len) (char=? #\! (string-ref prefix i))] + [substring prefix 0 i]))) + +(define (string-after s start) + (let ([s-len (string-length s)] [with-len (string-length start)]) + (and (>= s-len with-len) + (string=? (substring s 0 with-len) start) + (substring s with-len s-len)))) + +; --- Calculator --------------------------------------------------------------- + +; Evaluator derived from the example in The Scheme Programming Language. +; +; Even though EVAL with a carefully crafted environment would also do a good +; job at sandboxing, it would probably be impossible to limit execution time... + +(define (env-new formals actuals env) + (cond [(null? formals) env] + [(symbol? formals) (cons (cons formals actuals) env)] + [else (cons (cons (car formals) (car actuals)) + (env-new (cdr formals) (cdr actuals) env))])) +(define (env-lookup var env) (cdr (assq var env))) +(define (env-assign var val env) (set-cdr! (assq var env) val)) + +(define (check-reductions r) + (if (= (car r) 0) + (error 'check-reductions "reduction limit exceeded") + (set-car! r (- (car r) 1)))) + +; TODO - think about implementing more syntactical constructs, +; however there's not much point in having anything else in a calculator... +(define (exec expr r env) + (check-reductions r) + (cond [(symbol? expr) (env-lookup expr env)] + [(pair? expr) + (case (car expr) + [(quote) (cadr expr)] + [(lambda) (lambda vals + (let ([env (env-new (cadr expr) vals env)]) + (let loop ([exprs (cddr expr)]) + (if (null? (cdr exprs)) + (exec (car exprs) r env) + (begin (exec (car exprs) r env) + (loop (cdr exprs)))))))] + [(if) (if (exec (cadr expr) r env) + (exec (caddr expr) r env) + (exec (cadddr expr) r env))] + [(set!) (env-assign (cadr expr) (exec (caddr expr) r env) env)] + [else (apply (exec (car expr) r env) + (map (lambda (x) (exec x r env)) (cdr expr)))])] + [else expr])) + +(define-syntax forward + (syntax-rules () + [(_) '()] + [(_ a b ...) (cons (cons (quote a) a) (forward b ...))])) + +; ...which can't prevent me from simply importing most of the standard library +(define base-library + (forward + ; Equivalence, procedure predicate, booleans + eqv? eq? equal? procedure? boolean? boolean=? not + ; numbers, numerical input and output + number? complex? real? rational? integer? exact? inexact? exact inexact + real-valued? rational-valued? integer-valued? number->string string->number + ; Arithmetic + = < > <= >= zero? positive? negative? odd? even? finite? infinite? nan? + min max + * - / abs div-and-mod div mod div0-and-mod0 div0 mod0 + gcd lcm numerator denominator floor ceiling truncate round + rationalize exp log sin cos tan asin acos atan sqrt expt + make-rectangular make-polar real-part imag-part magnitude angle + ; Pairs and lists + map for-each cons car cdr caar cadr cdar cddr + caaar caadr cadar caddr cdaar cdadr cddar cdddr + caaaar caaadr caadar caaddr cadaar cadadr caddar cadddr + cdaaar cdaadr cdadar cdaddr cddaar cddadr cdddar cddddr + pair? null? list? list length append reverse list-tail list-ref + ; Symbols + symbol? symbol=? symbol->string string->symbol + ; Characters + char? char=? char<? char>? char<=? char>=? char->integer integer->char + ; Strings; XXX - omitted make-string - can cause OOM + string? string=? string<? string>? string<=? string>=? + string string-length string-ref substring + string-append string->list list->string string-for-each string-copy + ; Vectors; XXX - omitted make-vector - can cause OOM + vector? vector vector-length vector-ref vector-set! + vector->list list->vector vector-fill! vector-map vector-for-each + ; Control features + apply call/cc values call-with-values dynamic-wind)) +(define extended-library + (forward + char-upcase char-downcase char-titlecase char-foldcase + char-ci=? char-ci<? char-ci>? char-ci<=? char-ci>=? + char-alphabetic? char-numeric? char-whitespace? + char-upper-case? char-lower-case? char-title-case? + string-upcase string-downcase string-titlecase string-foldcase + string-ci=? string-ci<? string-ci>? string-ci<=? string-ci>=? + find for-all exists filter partition fold-left fold-right + remp remove remv remq memp member memv memq assp assoc assv assq cons* + list-sort vector-sort vector-sort! + bitwise-not bitwise-and bitwise-ior bitwise-xor bitwise-if + bitwise-bit-count bitwise-length bitwise-first-bit-set bitwise-bit-set? + bitwise-copy-bit bitwise-bit-field bitwise-copy-bit-field + bitwise-arithmetic-shift bitwise-rotate-bit-field bitwise-reverse-bit-field + bitwise-arithmetic-shift-left bitwise-arithmetic-shift-right + set-car! set-cdr! string-set! string-fill!)) +(define (interpret expr) + (exec expr '(2000) (append base-library extended-library))) + +; We could show something a bit nicer but it would be quite Guile-specific +(define (error-string e) + (map (lambda (x) (string-append " " (symbol->string x))) + (filter (lambda (x) (not (member x '(&who &message &irritants &guile)))) + (map (lambda (x) (record-type-name (record-rtd x))) + (simple-conditions e))))) + +(define (calc input respond) + (define (stringify x) + (call-with-string-output-port (lambda (port) (write x port)))) + (guard (e [else (display-exception e (current-error-port)) + (apply respond "caught" (error-string e))]) + (let* ([input (open-string-input-port input)] + [data (let loop () + (define datum (get-datum input)) + (if (eof-object? datum) '() (cons datum (loop))))]) + (call-with-values + (lambda () (interpret (list (append '(lambda ()) data)))) + (lambda message + (for-all (lambda (x) (respond (stringify x))) message)))))) + +; --- Main loop ---------------------------------------------------------------- + +(define prefix (get-config "prefix")) +(send "XB register") + +(define (process msg) + (when (string-ci=? (message-command msg) "PRIVMSG") + (let* ([nick (extract-nick (message-prefix msg))] + [target (car (message-params msg))] + [response-begin + (apply string-append "PRIVMSG " + (if (memv (string-ref target 0) (string->list "#&!+")) + `(,target " :" ,nick ": ") `(,nick " :")))] + [respond (lambda args (apply send response-begin args))] + [text (cadr (message-params msg))] + [input (or (string-after text (string-append prefix "calc ")) + (string-after text (string-append prefix "= ")))]) + (when input (calc input respond))))) + +(let main-loop () + (define line (get-line-crlf irc-input-port)) + (unless (eof-object? line) + (guard (e [else (display-exception e (current-error-port))]) + (unless (string=? "" line) + (process (parse-message line)))) + (main-loop))) diff --git a/plugins/xB/coin b/plugins/xB/coin new file mode 100755 index 0000000..770cb32 --- /dev/null +++ b/plugins/xB/coin @@ -0,0 +1,128 @@ +#!/usr/bin/env tclsh +# +# xB coin plugin, random number-based utilities +# +# Copyright 2012, 2014 Přemysl Eric Janouch +# See the file LICENSE for licensing information. +# + +# This is a terrible excuse for a programming language and I feel dirty. + +proc parse {line} { + global msg + unset -nocomplain msg + + if [regexp {^:([^ ]*) *(.*)} $line -> prefix rest] { + set msg(prefix) $prefix + set line $rest + } + if [regexp {^([^ ]*) *(.*)} $line -> command rest] { + set msg(command) $command + set line $rest + } + while {1} { + set line [string trimleft $line " "] + set i [string first " " $line] + if {$i == -1} { set i [string length $line] } + if {$i == 0} { break } + + if {[string index $line 0] == ":"} { + lappend msg(param) [string range $line 1 end] + break + } + lappend msg(param) [string range $line 0 [expr $i - 1]] + set line [string range $line $i end] + } +} + +proc get_config {key} { + global msg + puts "XB get_config :$key" + gets stdin line + parse $line + return [lindex $msg(param) 0] +} + +proc pmrespond {text} { + global ctx + global ctx_quote + puts "PRIVMSG $ctx :$ctx_quote$text" +} + +fconfigure stdin -translation crlf -encoding iso8859-1 +fconfigure stdout -translation crlf -encoding iso8859-1 + +set prefix [get_config prefix] +puts "XB register" + +set eightball [list \ + "It is certain" \ + "It is decidedly so" \ + "Without a doubt" \ + "Yes - definitely" \ + "You may rely on it" \ + "As I see it, yes" \ + "Most likely" \ + "Outlook good" \ + "Yes" \ + "Signs point to yes" \ + "Reply hazy, try again" \ + "Ask again later" \ + "Better not tell you now" \ + "Cannot predict now" \ + "Concentrate and ask again" \ + "Don't count on it" \ + "My reply is no" \ + "My sources say no" \ + "Outlook not so good" \ + "Very doubtful"] + +while {[gets stdin line] != -1} { + parse $line + + if {! [info exists msg(prefix)] || ! [info exists msg(command)] + || $msg(command) != "PRIVMSG" || ! [info exists msg(param)] + || [llength $msg(param)] < 2} { continue } + + regexp {^[^!]*} $msg(prefix) ctx + if [regexp {^[#&+!]} [lindex $msg(param) 0]] { + set ctx_quote "$ctx: " + set ctx [lindex $msg(param) 0] + } else { set ctx_quote "" } + + set input [lindex $msg(param) 1] + set first_chars [string range $input 0 \ + [expr [string length $prefix] - 1]] + if {$first_chars != $prefix} { continue } + set input [string range $input [string length $prefix] end] + + if {$input == "coin"} { + if {rand() < 0.5} { + pmrespond "Heads." + } else { + pmrespond "Tails." + } + } elseif {[regexp {^dice( +|$)(.*)} $input -> _ args]} { + if {! [string is integer -strict $args] || $args <= 0} { + pmrespond "Invalid or missing number." + } else { + pmrespond [expr {int($args * rand()) + 1}] + } + } elseif {[regexp {^(choose|\?)( +|$)(.*)} $input -> _ _ args]} { + if {$args == ""} { + pmrespond "Nothing to choose from." + } else { + set c [split $args ",|"] + pmrespond [string trim [lindex $c \ + [expr {int([llength $c] * rand())}]]] + } + } elseif {[regexp {^eightball( +|$)(.*)} $input -> _ args]} { + if {$args == ""} { + pmrespond "You should, you know, ask something." + } else { + pmrespond [lindex $eightball \ + [expr {int([llength $eightball] * rand())}]]. + } + } +} + diff --git a/plugins/xB/eval b/plugins/xB/eval new file mode 100755 index 0000000..48ea28d --- /dev/null +++ b/plugins/xB/eval @@ -0,0 +1,312 @@ +#!/usr/bin/awk -f +# +# xB eval plugin, LISP-like expression evaluator +# +# Copyright 2013, 2014 Přemysl Eric Janouch +# See the file LICENSE for licensing information. +# + +BEGIN \ +{ + RS = "\r" + ORS = "\r\n" + IGNORECASE = 1 + srand() + + prefix = get_config("prefix") + + print "XB register" + fflush("") + + # All functions have to be in this particular array + min_args["int"] = 1 + min_args["+"] = 1 + min_args["-"] = 1 + min_args["*"] = 1 + min_args["/"] = 1 + min_args["%"] = 1 + min_args["^"] = 1 + min_args["**"] = 1 + min_args["exp"] = 1 + min_args["sin"] = 1 + min_args["cos"] = 1 + min_args["atan2"] = 2 + min_args["log"] = 1 + min_args["rand"] = 0 + min_args["sqrt"] = 1 + + min_args["pi"] = 0 + min_args["e"] = 0 + + min_args["min"] = 1 + min_args["max"] = 1 + + # Whereas here their presence is only optional + max_args["int"] = 1 + max_args["sin"] = 1 + max_args["cos"] = 1 + max_args["atan2"] = 2 + max_args["log"] = 1 + max_args["rand"] = 0 + max_args["sqrt"] = 1 + + max_args["pi"] = 0 + max_args["e"] = 0 +} + +{ + parse($0) +} + +msg_command == "PRIVMSG" \ +{ + # Context = either channel or user nickname + match(msg_prefix, /^[^!]+/) + ctx = substr(msg_prefix, RSTART, RLENGTH) + if (msg_param[0] ~ /^[#&!+]/) + { + ctx_quote = ctx ": " + ctx = msg_param[0] + } + else + ctx_quote = "" + + + if (substr(msg_param[1], 1, length(prefix)) == prefix) + { + keyword = "eval" + text = substr(msg_param[1], 1 + length(prefix)) + if (match(text, "^" keyword "([^A-Za-z0-9].*|$)")) + process_request(substr(text, 1 + length(keyword))) + } +} + +{ + fflush("") +} + +function pmrespond (text) +{ + print "PRIVMSG " ctx " :" ctx_quote text +} + +function process_request (input, res, x) +{ + delete funs + delete accumulator + delete n_args + + res = "" + fun_top = 0 + funs[0] = "" + accumulator[0] = 0 + n_args[0] = 0 + + if (match(input, "^[ \t]*")) + input = substr(input, RLENGTH + 1) + if (input == "") + res = "expression missing" + + while (res == "" && input != "") { + if (match(input, "^-?[0-9]+\\.?[0-9]*")) { + x = substr(input, RSTART, RLENGTH) + input = substr(input, RLENGTH + 1) + + match(input, "^ *") + input = substr(input, RLENGTH + 1) + + res = process_argument(x) + } else if (match(input, "^[(]([^ ()]+)")) { + x = substr(input, RSTART + 1, RLENGTH - 1) + input = substr(input, RLENGTH + 1) + + match(input, "^ *") + input = substr(input, RLENGTH + 1) + + if (!(x in min_args)) { + res = "undefined function '" x "'" + } else { + fun_top++ + funs[fun_top] = x + accumulator[fun_top] = 636363 + n_args[fun_top] = 0 + } + } else if (match(input, "^[)] *")) { + input = substr(input, RLENGTH + 1) + res = process_end() + } else + res = "invalid input at '" substr(input, 1, 10) "...'" + } + + if (res == "") { + if (fun_top != 0) + res = "unclosed '" funs[fun_top] "'" + else if (n_args[0] != 1) + res = "internal error, expected one result" \ + ", got " n_args[0] " instead" + } + + if (res == "") + pmrespond(accumulator[0]) + else + pmrespond(res) +} + +function process_argument (arg) +{ + if (fun_top == 0) { + if (n_args[0]++ != 0) + return "too many results, I only expect one" + + accumulator[0] = arg + return "" + } + + fun = funs[fun_top] + if (fun in max_args && max_args[fun] <= n_args[fun_top]) + return "too many operands for " fun + + if (fun == "int") { + accumulator[fun_top] = int(arg) + } else if (fun == "+") { + if (n_args[fun_top] == 0) + accumulator[fun_top] = arg + else + accumulator[fun_top] += arg + } else if (fun == "-") { + if (n_args[fun_top] == 0) + accumulator[fun_top] = arg + else + accumulator[fun_top] -= arg + } else if (fun == "*") { + if (n_args[fun_top] == 0) + accumulator[fun_top] = arg + else + accumulator[fun_top] *= arg + } else if (fun == "/") { + if (n_args[fun_top] == 0) + accumulator[fun_top] = arg + else if (arg == 0) + return "division by zero" + else + accumulator[fun_top] /= arg + } else if (fun == "%") { + if (n_args[fun_top] == 0) + accumulator[fun_top] = arg + else if (arg == 0) + return "division by zero" + else + accumulator[fun_top] %= arg + } else if (fun == "^" || fun == "**" || fun == "exp") { + if (n_args[fun_top] == 0) + accumulator[fun_top] = arg + else + accumulator[fun_top] ^= arg + } else if (fun == "sin") { + accumulator[fun_top] = sin(arg) + } else if (fun == "cos") { + accumulator[fun_top] = cos(arg) + } else if (fun == "atan2") { + if (n_args[fun_top] == 0) + accumulator[fun_top] = arg + else + accumulator[fun_top] = atan2(accumulator[fun_top], arg) + } else if (fun == "log") { + accumulator[fun_top] = log(arg) + } else if (fun == "rand") { + # Just for completeness, execution never gets here + } else if (fun == "sqrt") { + accumulator[fun_top] = sqrt(arg) + } else if (fun == "min") { + if (n_args[fun_top] == 0) + accumulator[fun_top] = arg + else if (accumulator[fun_top] > arg) + accumulator[fun_top] = arg + } else if (fun == "max") { + if (n_args[fun_top] == 0) + accumulator[fun_top] = arg + else if (accumulator[fun_top] < arg) + accumulator[fun_top] = arg + } else + return "internal error, unhandled operands for " fun + + n_args[fun_top]++ + return "" +} + +function process_end () +{ + if (fun_top <= 0) + return "extraneous ')'" + + fun = funs[fun_top] + if (!(fun in min_args)) + return "internal error, unhandled ')' for '" fun "'" + if (min_args[fun] > n_args[fun_top]) + return "not enough operands for '" fun "'" + + # There's no 'init' function to do it in + if (fun == "rand") + accumulator[fun_top] = rand() + else if (fun == "pi") + accumulator[fun_top] = 3.141592653589793 + else if (fun == "e") + accumulator[fun_top] = 2.718281828459045 + + return process_argument(accumulator[fun_top--]) +} + +function get_config (key) +{ + print "XB get_config :" key + fflush("") + + getline + parse($0) + return msg_param[0] +} + +function parse (line, s, n, id, token) +{ + s = 1 + id = 0 + + # NAWK only uses the first character of RS + if (line ~ /^\n/) + line = substr(line, 2) + + msg_prefix = "" + msg_command = "" + delete msg_param + + n = match(substr(line, s), / |$/) + while (n) + { + token = substr(line, s, n - 1) + if (token ~ /^:/) + { + if (s == 1) + msg_prefix = substr(token, 2) + else + { + msg_param[id] = substr(line, s + 1) + break + } + } + else if (!msg_command) + msg_command = toupper(token) + else + msg_param[id++] = token + + s = s + n + n = index(substr(line, s), " ") + + if (!n) + { + n = length(substr(line, s)) + 1 + if (n == 1) + break; + } + } +} + diff --git a/plugins/xB/factoids b/plugins/xB/factoids new file mode 100755 index 0000000..84f6559 --- /dev/null +++ b/plugins/xB/factoids @@ -0,0 +1,177 @@ +#!/usr/bin/env perl +# +# xB factoids plugin +# +# Copyright 2016 Přemysl Eric Janouch <p@janouch.name> +# See the file LICENSE for licensing information. +# + +use strict; +use warnings; +use Text::Wrap; + +# --- IRC protocol ------------------------------------------------------------- + +binmode STDIN; select STDIN; $| = 1; $/ = "\r\n"; +binmode STDOUT; select STDOUT; $| = 1; $\ = "\r\n"; + +sub parse ($) { + chomp (my $line = shift); + return undef unless my ($nick, $user, $host, $command, $args) = ($line =~ + qr/^(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(?: +(.*))?$/o); + return {nick => $nick, user => $user, host => $host, command => $command, + args => defined $args ? [$args =~ /:?((?<=:).*|[^ ]+) */og] : []}; +} + +sub bot_print { + print "XB print :${\shift}"; +} + +# --- Initialization ----------------------------------------------------------- + +my %config; +for my $name (qw(prefix)) { + print "XB get_config :$name"; + $config{$name} = (parse <STDIN>)->{args}->[0]; +} + +print "XB register"; + +# --- Database ----------------------------------------------------------------- +# Simple map of (factoid_name => [definitions]); all factoids are separated +# by newlines and definitions by carriage returns. Both disallowed in IRC. + +sub db_load { + local $/ = "\n"; + my ($path) = @_; + open my $db, "<", $path or return {}; + + my %entries; + while (<$db>) { + chomp; + my @defs = split "\r"; + $entries{shift @defs} = \@defs; + } + \%entries +} + +sub db_save { + local $\ = "\n"; + my ($path, $ref) = @_; + my $path_new = "$path.new"; + open my $db, ">", $path_new or die "db save failed: $!"; + + my %entries = %$ref; + print $db join "\r", ($_, @{$entries{$_}}) for keys %entries; + close $db; + rename $path_new, $path or die "db save failed: $!"; +} + +# --- Factoids ----------------------------------------------------------------- + +my $db_path = 'factoids.db'; +my %db = %{db_load $db_path}; + +sub learn { + my ($respond, $input) = @_; + return &$respond("usage: <name> = <definition>") + unless $input =~ /^([^=]+?)(?:\s+(\d+))?\s*=\s*(.+?)\s*$/; + + my ($name, $number, $definition) = ($1, $2, $3); + return &$respond("trailing numbers in names are disallowed") + if defined $2; + $db{$name} = [] unless exists $db{$name}; + + my $entries = $db{$name}; + return &$respond("duplicate definition") + if grep { lc $_ eq lc $definition } @$entries; + + push @$entries, $definition; + &$respond("saved as #${\scalar @$entries}"); + db_save $db_path, \%db; +} + +sub check_number { + my ($respond, $name, $number) = @_; + my $entries = $db{$name}; + if ($number > @$entries) { + &$respond(qq/"$name" has only ${\scalar @$entries} definitions/); + } elsif (not $number) { + &$respond("number must not be zero"); + } else { + return 1; + } + return 0; +} + +sub forget { + my ($respond, $input) = @_; + return &$respond("usage: <name> <number>") + unless $input =~ /^([^=]+?)\s+(\d+)\s*$/; + + my ($name, $number) = ($1, int($2)); + return &$respond(qq/"$name" is undefined/) + unless exists $db{$name}; + + my $entries = $db{$name}; + return unless check_number $respond, $name, $number; + + splice @$entries, --$number, 1; + &$respond("forgotten"); + db_save $db_path, \%db; +} + +sub whatis { + my ($respond, $input) = @_; + return &$respond("usage: <name> [<number>]") + unless $input =~ /^([^=]+?)(?:\s+(\d+))?\s*$/; + + my ($name, $number) = ($1, $2); + return &$respond(qq/"$name" is undefined/) + unless exists $db{$name}; + + my $entries = $db{$name}; + if (defined $number) { + return unless check_number $respond, $name, $number; + &$respond(qq/"$name" is #$number $entries->[$number - 1]/); + } else { + my $i = 1; + my $definition = join ", ", map { "#${\$i++} $_" } @{$entries}; + &$respond(qq/"$name" is $definition/); + } +} + +sub wildcard { + my ($respond, $input) = @_; + $input =~ /=/ ? learn(@_) : whatis(@_); +} + +my %commands = ( + 'learn' => \&learn, + 'forget' => \&forget, + 'whatis' => \&whatis, + '??' => \&wildcard, +); + +# --- Input loop --------------------------------------------------------------- + +while (my $line = <STDIN>) { + my %msg = %{parse $line}; + my @args = @{$msg{args}}; + + # This plugin only bothers to respond to PRIVMSG messages + next unless $msg{command} eq 'PRIVMSG' and @args >= 2 + and my ($cmd, $input) = $args[1] =~ /^$config{prefix}(\S+)\s*(.*)/; + + # So far the only reaction is a PRIVMSG back to the sender, so all the + # handlers need is a response callback and all arguments to the command + my ($target => $quote) = ($args[0] =~ /^[#+&!]/) + ? ($args[0] => "$msg{nick}: ") : ($msg{nick} => ''); + # Wrap all responses so that there's space for our prefix in the message + my $respond = sub { + local ($Text::Wrap::columns, $Text::Wrap::unexpand) = 400, 0; + my $start = "PRIVMSG $target :$quote"; + print for split "\n", wrap $start, $start, shift; + }; + &{$commands{$cmd}}($respond, $input) if exists($commands{$cmd}); +} diff --git a/plugins/xB/pomodoro b/plugins/xB/pomodoro new file mode 100755 index 0000000..910f707 --- /dev/null +++ b/plugins/xB/pomodoro @@ -0,0 +1,502 @@ +#!/usr/bin/env ruby +# coding: utf-8 +# +# xB pomodoro plugin +# +# Copyright 2015 Přemysl Eric Janouch +# See the file LICENSE for licensing information. +# + +# --- Simple event loop -------------------------------------------------------- + +# This is more or less a straight-forward port of my C event loop. It's a bit +# unfortunate that I really have to implement all this in order to get some +# basic asynchronicity but at least I get to exercise my Ruby. + +class TimerEvent + attr_accessor :index, :when, :callback + + def initialize (callback) + raise ArgumentError unless callback.is_a? Proc + + @index = nil + @when = nil + @callback = callback + end + + def active? + @index != nil + end + + def until + return @when - Time.new + end +end + +class IOEvent + READ = 1 << 0 + WRITE = 1 << 1 + + attr_accessor :read_index, :write_index, :io, :callback + + def initialize (io, callback) + raise ArgumentError unless callback.is_a? Proc + + @read_index = nil + @write_index = nil + @io = io + @callback = callback + end +end + +class EventLoop + def initialize + @running = false + @timers = [] + @readers = [] + @writers = [] + @io_to_event = {} + end + + def set_timer (timer, timeout) + raise ArgumentError unless timer.is_a? TimerEvent + + timer.when = Time.now + timeout + if timer.index + heapify_down timer.index + heapify_up timer.index + else + timer.index = @timers.size + @timers.push timer + heapify_up timer.index + end + end + + def reset_timer (timer) + raise ArgumentError unless timer.is_a? TimerEvent + remove_timer_at timer.index if timer.index + end + + def set_io (io_event, events) + raise ArgumentError unless io_event.is_a? IOEvent + raise ArgumentError unless events.is_a? Numeric + + reset_io io_event + + @io_to_event[io_event.io] = io_event + if events & IOEvent::READ + io_event.read_index = @readers.size + @readers.push io_event.io + end + if events & IOEvent::WRITE + io_event.read_index = @writers.size + @writers.push io_event.io + end + end + + def reset_io (io_event) + raise ArgumentError unless io_event.is_a? IOEvent + + @readers.delete_at io_event.read_index if io_event.read_index + @writers.delete_at io_event.write_index if io_event.write_index + + io_event.read_index = nil + io_event.write_index = nil + + @io_to_event.delete io_event.io + end + + def run + @running = true + while @running do one_iteration end + end + + def quit + @running = false + end + +private + def one_iteration + rs, ws, = IO.select @readers, @writers, [], nearest_timeout + dispatch_timers + (Array(rs) | Array(ws)).each do |io| + @io_to_event[io].callback.call io + end + end + + def dispatch_timers + now = Time.new + while not @timers.empty? and @timers[0].when <= now do + @timers[0].callback.call + remove_timer_at 0 + end + end + + def nearest_timeout + return nil if @timers.empty? + timeout = @timers[0].until + if timeout < 0 then 0 else timeout end + end + + def remove_timer_at (index) + @timers[index].index = nil + moved = @timers.pop + return if index == @timers.size + + @timers[index] = moved + @timers[index].index = index + heapify_down index + end + + def swap_timers (a, b) + @timers[a], @timers[b] = @timers[b], @timers[a] + @timers[a].index = a + @timers[b].index = b + end + + def heapify_up (index) + while index != 0 do + parent = (index - 1) / 2 + break if @timers[parent].when <= @timers[index].when + swap_timers index, parent + index = parent + end + end + + def heapify_down (index) + loop do + parent = index + left = 2 * index + 1 + right = 2 * index + 2 + + lowest = parent + lowest = left if left < @timers.size and + @timers[left] .when < @timers[lowest].when + lowest = right if right < @timers.size and + @timers[right].when < @timers[lowest].when + break if parent == lowest + + swap_timers lowest, parent + index = lowest + end + end +end + +# --- IRC protocol ------------------------------------------------------------- + +$stdin.set_encoding 'ASCII-8BIT' +$stdout.set_encoding 'ASCII-8BIT' + +$stdin.sync = true +$stdout.sync = true + +$/ = "\r\n" +$\ = "\r\n" + +RE_MSG = /(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(?: +(.*))?$/ +RE_ARGS = /:?((?<=:).*|[^ ]+) */ + +def parse (line) + m = line.match RE_MSG + return nil if not m + + nick, user, host, command, args = *m.captures + args = if args then args.scan(RE_ARGS).flatten else [] end + [nick, user, host, command, args] +end + +def bot_print (what) + print "XB print :#{what}" +end + +# --- Initialization ----------------------------------------------------------- + +# We can only read in configuration from here so far +# To read it from anywhere else, it has to be done asynchronously +$config = {} +[:prefix].each do |name| + print "XB get_config :#{name}" + _, _, _, _, args = *parse($stdin.gets.chomp) + $config[name] = args[0] +end + +print "XB register" + +# --- Plugin logic ------------------------------------------------------------- + +# FIXME: this needs a major refactor as it doesn't make much sense at all + +class MessageMeta < Struct.new(:nick, :user, :host, :channel, :ctx, :quote) + def respond (message) + print "PRIVMSG #{ctx} :#{quote}#{message}" + end +end + +class Context + attr_accessor :nick, :ctx + + def initialize (meta) + @nick = meta.nick + @ctx = meta.ctx + end + + def == (other) + self.class == other.class \ + and other.nick == @nick \ + and other.ctx == @ctx + end + + alias eql? == + + def hash + @nick.hash ^ @ctx.hash + end +end + +class PomodoroTimer + def initialize (context) + @ctx = context.ctx + @nicks = [context.nick] + + @timer_work = TimerEvent.new(lambda { on_work }) + @timer_rest = TimerEvent.new(lambda { on_rest }) + + on_work + end + + def inform (message) + # FIXME: it tells the nick even in PM's + quote = "#{@nicks.join(" ")}: " + print "PRIVMSG #{@ctx} :#{quote}#{message}" + end + + def on_work + inform "work now!" + $loop.set_timer @timer_rest, 25 * 60 + end + + def on_rest + inform "rest now!" + $loop.set_timer @timer_work, 5 * 60 + end + + def join (meta) + return if @nicks.include? meta.nick + + meta.respond "you have joined their pomodoro" + @nicks |= [meta.nick] + end + + def part (meta, requested) + return if not @nicks.include? meta.nick + + if requested + meta.respond "you have stopped your pomodoro" + end + + @nicks -= [meta.nick] + if @nicks.empty? + $loop.reset_timer @timer_work + $loop.reset_timer @timer_rest + end + end + + def status (meta) + return if not @nicks.include? meta.nick + + if @timer_rest.active? + till = @timer_rest.until + meta.respond "working, #{(till / 60).to_i} minutes, " + + "#{(till % 60).to_i} seconds until rest" + end + if @timer_work.active? + till = @timer_work.until + meta.respond "resting, #{(till / 60).to_i} minutes, " + + "#{(till % 60).to_i} seconds until work" + end + end +end + +class Pomodoro + KEYWORD = "pomodoro" + + def initialize + @timers = {} + end + + def on_help (meta, args) + meta.respond "usage: #{KEYWORD} { start | stop | join <nick> | status }" + end + + def on_start (meta, args) + if args.size != 0 + meta.respond "usage: #{KEYWORD} start" + return + end + + context = Context.new meta + if @timers[context] + meta.respond "you already have a timer running here" + else + @timers[context] = PomodoroTimer.new meta + end + end + + def on_join (meta, args) + if args.size != 1 + meta.respond "usage: #{KEYWORD} join <nick>" + return + end + + context = Context.new meta + if @timers[context] + meta.respond "you already have a timer running here" + return + end + + joined_context = Context.new meta + joined_context.nick = args.shift + timer = @timers[joined_context] + if not timer + meta.respond "that person doesn't have a timer here" + else + timer.join meta + @timers[context] = timer + end + end + + def on_stop (meta, args) + if args.size != 0 + meta.respond "usage: #{KEYWORD} stop" + return + end + + context = Context.new meta + timer = @timers[context] + if not timer + meta.respond "you don't have a timer running here" + else + timer.part meta, true + @timers.delete context + end + end + + def on_status (meta, args) + if args.size != 0 + meta.respond "usage: #{KEYWORD} status" + return + end + + timer = @timers[Context.new meta] + if not timer + meta.respond "you don't have a timer running here" + else + timer.status meta + end + end + + def process_command (meta, msg) + args = msg.split + return if args.shift != KEYWORD + + method = "on_#{args.shift}" + send method, meta, args if respond_to? method + end + + def on_server_nick (meta, command, args) + # TODO: either handle this properly... + happened = false + @timers.keys.each do |key| + next if key.nick != meta.nick + @timers[key].part meta, false + @timers.delete key + happened = true + end + if happened + # TODO: ...or at least inform the user via his new nick + end + end + + def on_server_part (meta, command, args) + # TODO: instead of cancelling the user's pomodoros, either redirect + # them to PM's and later upon rejoining undo the redirection... + context = Context.new(meta) + context.ctx = meta.channel + if @timers.include? context + # TODO: ...or at least inform the user about the cancellation + @timers[context].part meta, false + @timers.delete context + end + end + + def on_server_quit (meta, command, args) + @timers.keys.each do |key| + next if key.nick != meta.nick + @timers[key].part meta, false + @timers.delete key + end + end + + def process (meta, command, args) + method = "on_server_#{command.downcase}" + send method, meta, command, args if respond_to? method + end +end + +# --- IRC message processing --------------------------------------------------- + +$handlers = [Pomodoro.new] +def process_line (line) + msg = parse line + return if not msg + + nick, user, host, command, args = *msg + + context = nick + quote = "" + channel = nil + + if args.size >= 1 and args[0].start_with? ?#, ?+, ?&, ?! + case command + when "PRIVMSG", "NOTICE", "JOIN" + context = args[0] + quote = "#{nick}: " + channel = args[0] + when "PART" + channel = args[0] + end + end + + # Handle any IRC message + meta = MessageMeta.new(nick, user, host, channel, context, quote).freeze + $handlers.each do |handler| + handler.process meta, command, args + end + + # Handle pre-processed bot commands + if command == 'PRIVMSG' and args.size >= 2 + msg = args[1] + return unless msg.start_with? $config[:prefix] + $handlers.each do |handler| + handler.process_command meta, msg[$config[:prefix].size..-1] + end + end +end + +buffer = "" +stdin_io = IOEvent.new($stdin, lambda do |io| + begin + buffer << io.read_nonblock(4096) + lines = buffer.split $/, -1 + buffer = lines.pop + lines.each { |line| process_line line } + rescue EOFError + $loop.quit + rescue IO::WaitReadable + # Ignore + end +end) + +$loop = EventLoop.new +$loop.set_io stdin_io, IOEvent::READ +$loop.run diff --git a/plugins/xB/script b/plugins/xB/script new file mode 100755 index 0000000..43bd66f --- /dev/null +++ b/plugins/xB/script @@ -0,0 +1,2310 @@ +#!/usr/bin/tcc -run -lm +// +// xB scripting plugin, using a custom stack-based language +// +// Copyright 2014 Přemysl Eric Janouch +// See the file LICENSE for licensing information. +// +// Just compile this file as usual (sans #!) if you don't feel like using TCC. +// It is a very basic and portable C99 application. It's not supposed to be +// very sophisticated, for it'd get extremely big. +// +// The main influences of the language were Factor and Joy, stripped of all +// even barely complex stuff. In its current state, it's only really useful as +// a calculator but it's got great potential for extending. +// +// If you don't like something, just change it; this is just an experiment. +// +// NOTE: it is relatively easy to abuse. Be careful. +// + +#define _XOPEN_SOURCE 500 + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <ctype.h> +#include <errno.h> +#include <stdarg.h> +#include <assert.h> +#include <time.h> +#include <stdbool.h> +#include <strings.h> +#include <math.h> + +#define ADDRESS_SPACE_LIMIT (100 * 1024 * 1024) +#include <sys/resource.h> + +#if defined __GNUC__ +#define ATTRIBUTE_PRINTF(x, y) __attribute__ ((format (printf, x, y))) +#else // ! __GNUC__ +#define ATTRIBUTE_PRINTF(x, y) +#endif // ! __GNUC__ + +#define N_ELEMENTS(a) (sizeof (a) / sizeof ((a)[0])) + +// --- Utilities --------------------------------------------------------------- + +static char *strdup_printf (const char *format, ...) ATTRIBUTE_PRINTF (1, 2); + +static char * +strdup_vprintf (const char *format, va_list ap) +{ + va_list aq; + va_copy (aq, ap); + int size = vsnprintf (NULL, 0, format, aq); + va_end (aq); + if (size < 0) + return NULL; + + char buf[size + 1]; + size = vsnprintf (buf, sizeof buf, format, ap); + if (size < 0) + return NULL; + + return strdup (buf); +} + +static char * +strdup_printf (const char *format, ...) +{ + va_list ap; + va_start (ap, format); + char *result = strdup_vprintf (format, ap); + va_end (ap); + return result; +} + +// --- Generic buffer ---------------------------------------------------------- + +struct buffer +{ + char *s; ///< Buffer data + size_t alloc; ///< Number of bytes allocated + size_t len; ///< Number of bytes used + bool memory_failure; ///< Memory allocation failed +}; + +#define BUFFER_INITIALIZER { NULL, 0, 0, false } + +static bool +buffer_append (struct buffer *self, const void *s, size_t n) +{ + if (self->memory_failure) + return false; + + if (!self->s) + self->s = malloc (self->alloc = 8); + while (self->len + n > self->alloc) + self->s = realloc (self->s, self->alloc <<= 1); + + if (!self->s) + { + self->memory_failure = true; + return false; + } + + memcpy (self->s + self->len, s, n); + self->len += n; + return true; +} + +inline static bool +buffer_append_c (struct buffer *self, char c) +{ + return buffer_append (self, &c, 1); +} + +// --- Data types -------------------------------------------------------------- + +enum item_type +{ + ITEM_STRING, + ITEM_WORD, + ITEM_INTEGER, + ITEM_FLOAT, + ITEM_LIST +}; + +struct item +{ +#define ITEM_HEADER \ + enum item_type type; /**< The type of this object */ \ + struct item *next; /**< Next item on the list/stack */ + + ITEM_HEADER +}; + +struct item_string +{ + ITEM_HEADER + size_t len; ///< Length of the string (sans '\0') + char value[]; ///< The null-terminated string value +}; + +#define get_string(item) \ + (assert ((item)->type == ITEM_STRING), \ + ((struct item_string *)(item))->value) + +/// It looks like a string but it doesn't quack like a string +#define item_word item_string + +#define get_word(item) \ + (assert ((item)->type == ITEM_WORD), \ + ((struct item_word *)(item))->value) + +struct item_integer +{ + ITEM_HEADER + long long value; ///< The integer value +}; + +#define get_integer(item) \ + (assert ((item)->type == ITEM_INTEGER), \ + ((struct item_integer *)(item))->value) + +struct item_float +{ + ITEM_HEADER + long double value; ///< The floating point value +}; + +#define get_float(item) \ + (assert ((item)->type == ITEM_FLOAT), \ + ((struct item_float *)(item))->value) + +struct item_list +{ + ITEM_HEADER + struct item *head; ///< The head of the list +}; + +#define get_list(item) \ + (assert ((item)->type == ITEM_LIST), \ + ((struct item_list *)(item))->head) + +#define set_list(item, head_) \ + (assert ((item)->type == ITEM_LIST), \ + item_free_list (((struct item_list *)(item))->head), \ + ((struct item_list *)(item))->head = (head_)) + +const char * +item_type_to_str (enum item_type type) +{ + switch (type) + { + case ITEM_STRING: return "string"; + case ITEM_WORD: return "word"; + case ITEM_INTEGER: return "integer"; + case ITEM_FLOAT: return "float"; + case ITEM_LIST: return "list"; + } + abort (); +} + +// --- Item management --------------------------------------------------------- + +static void item_free_list (struct item *); +static struct item *new_clone_list (const struct item *); + +static void +item_free (struct item *item) +{ + if (item->type == ITEM_LIST) + item_free_list (get_list (item)); + free (item); +} + +static void +item_free_list (struct item *item) +{ + while (item) + { + struct item *link = item; + item = item->next; + item_free (link); + } +} + +static struct item * +new_clone (const struct item *item) +{ + size_t size; + switch (item->type) + { + case ITEM_STRING: + case ITEM_WORD: + { + const struct item_string *x = (const struct item_string *) item; + size = sizeof *x + x->len + 1; + break; + } + case ITEM_INTEGER: size = sizeof (struct item_integer); break; + case ITEM_FLOAT: size = sizeof (struct item_float); break; + case ITEM_LIST: size = sizeof (struct item_list); break; + } + + struct item *clone = malloc (size); + if (!clone) + return NULL; + + memcpy (clone, item, size); + if (item->type == ITEM_LIST) + { + struct item_list *x = (struct item_list *) clone; + if (x->head && !(x->head = new_clone_list (x->head))) + { + free (clone); + return NULL; + } + } + clone->next = NULL; + return clone; +} + +static struct item * +new_clone_list (const struct item *item) +{ + struct item *head = NULL, *clone; + for (struct item **out = &head; item; item = item->next) + { + if (!(clone = *out = new_clone (item))) + { + item_free_list (head); + return NULL; + } + clone->next = NULL; + out = &clone->next; + } + return head; +} + +static struct item * +new_string (const char *s, ssize_t len) +{ + if (len < 0) + len = strlen (s); + + struct item_string *item = calloc (1, sizeof *item + len + 1); + if (!item) + return NULL; + + item->type = ITEM_STRING; + item->len = len; + memcpy (item->value, s, len); + item->value[len] = '\0'; + return (struct item *) item; +} + +static struct item * +new_word (const char *s, ssize_t len) +{ + struct item *item = new_string (s, len); + if (!item) + return NULL; + + item->type = ITEM_WORD; + return item; +} + +static struct item * +new_integer (long long value) +{ + struct item_integer *item = calloc (1, sizeof *item); + if (!item) + return NULL; + + item->type = ITEM_INTEGER; + item->value = value; + return (struct item *) item; +} + +static struct item * +new_float (long double value) +{ + struct item_float *item = calloc (1, sizeof *item); + if (!item) + return NULL; + + item->type = ITEM_FLOAT; + item->value = value; + return (struct item *) item; +} + +static struct item * +new_list (struct item *head) +{ + struct item_list *item = calloc (1, sizeof *item); + if (!item) + return NULL; + + item->type = ITEM_LIST; + item->head = head; + return (struct item *) item; +} + +// --- Parsing ----------------------------------------------------------------- + +#define PARSE_ERROR_TABLE(XX) \ + XX( OK, NULL ) \ + XX( EOF, "unexpected end of input" ) \ + XX( INVALID_HEXA_ESCAPE, "invalid hexadecimal escape sequence" ) \ + XX( INVALID_ESCAPE, "unrecognized escape sequence" ) \ + XX( MEMORY, "memory allocation failure" ) \ + XX( FLOAT_RANGE, "floating point value out of range" ) \ + XX( INTEGER_RANGE, "integer out of range" ) \ + XX( INVALID_INPUT, "invalid input" ) \ + XX( UNEXPECTED_INPUT, "unexpected input" ) + +enum tokenizer_error +{ +#define XX(x, y) PARSE_ERROR_ ## x, + PARSE_ERROR_TABLE (XX) +#undef XX + PARSE_ERROR_COUNT +}; + +struct tokenizer +{ + const char *cursor; + enum tokenizer_error error; +}; + +static bool +decode_hexa_escape (struct tokenizer *self, struct buffer *buf) +{ + int i; + char c, code = 0; + + for (i = 0; i < 2; i++) + { + c = tolower (*self->cursor); + if (c >= '0' && c <= '9') + code = (code << 4) | (c - '0'); + else if (c >= 'a' && c <= 'f') + code = (code << 4) | (c - 'a' + 10); + else + break; + + self->cursor++; + } + + if (!i) + return false; + + buffer_append_c (buf, code); + return true; +} + +static bool +decode_octal_escape (struct tokenizer *self, struct buffer *buf) +{ + int i; + char c, code = 0; + + for (i = 0; i < 3; i++) + { + c = *self->cursor; + if (c < '0' || c > '7') + break; + + code = (code << 3) | (c - '0'); + self->cursor++; + } + + if (!i) + return false; + + buffer_append_c (buf, code); + return true; +} + +static bool +decode_escape_sequence (struct tokenizer *self, struct buffer *buf) +{ + // Support some basic escape sequences from the C language + char c; + switch ((c = *self->cursor)) + { + case '\0': + self->error = PARSE_ERROR_EOF; + return false; + case 'x': + case 'X': + self->cursor++; + if (decode_hexa_escape (self, buf)) + return true; + + self->error = PARSE_ERROR_INVALID_HEXA_ESCAPE; + return false; + default: + if (decode_octal_escape (self, buf)) + return true; + + self->cursor++; + const char *from = "abfnrtv\"\\", *to = "\a\b\f\n\r\t\v\"\\", *x; + if ((x = strchr (from, c))) + { + buffer_append_c (buf, to[x - from]); + return true; + } + + self->error = PARSE_ERROR_INVALID_ESCAPE; + return false; + } +} + +static struct item * +parse_string (struct tokenizer *self) +{ + struct buffer buf = BUFFER_INITIALIZER; + struct item *item = NULL; + char c; + + while (true) + switch ((c = *self->cursor++)) + { + case '\0': + self->cursor--; + self->error = PARSE_ERROR_EOF; + goto end; + case '"': + if (buf.memory_failure + || !(item = new_string (buf.s, buf.len))) + self->error = PARSE_ERROR_MEMORY; + goto end; + case '\\': + if (decode_escape_sequence (self, &buf)) + break; + goto end; + default: + buffer_append_c (&buf, c); + } + +end: + free (buf.s); + return item; +} + +static struct item * +try_parse_number (struct tokenizer *self) +{ + // These two standard library functions can digest a lot of various inputs, + // including NaN and +/- infinity. That may get a bit confusing. + char *float_end; + errno = 0; + long double float_value = strtold (self->cursor, &float_end); + int float_errno = errno; + + char *int_end; + errno = 0; + long long int_value = strtoll (self->cursor, &int_end, 10); + int int_errno = errno; + + // If they both fail, then this is most probably not a number. + if (float_end == int_end && float_end == self->cursor) + return NULL; + + // Only use the floating point result if it parses more characters: + struct item *item; + if (float_end > int_end) + { + if (float_errno == ERANGE) + { + self->error = PARSE_ERROR_FLOAT_RANGE; + return NULL; + } + self->cursor = float_end; + if (!(item = new_float (float_value))) + self->error = PARSE_ERROR_MEMORY; + return item; + } + else + { + if (int_errno == ERANGE) + { + self->error = PARSE_ERROR_INTEGER_RANGE; + return NULL; + } + self->cursor = int_end; + if (!(item = new_integer (int_value))) + self->error = PARSE_ERROR_MEMORY; + return item; + } +} + +static struct item * +parse_word (struct tokenizer *self) +{ + struct buffer buf = BUFFER_INITIALIZER; + struct item *item = NULL; + char c; + + // Here we accept almost anything that doesn't break the grammar + while (!strchr (" []\"", (c = *self->cursor++)) && (unsigned char) c > ' ') + buffer_append_c (&buf, c); + self->cursor--; + + if (buf.memory_failure) + self->error = PARSE_ERROR_MEMORY; + else if (!buf.len) + self->error = PARSE_ERROR_INVALID_INPUT; + else if (!(item = new_word (buf.s, buf.len))) + self->error = PARSE_ERROR_MEMORY; + + free (buf.s); + return item; +} + +static struct item *parse_item_list (struct tokenizer *); + +static struct item * +parse_list (struct tokenizer *self) +{ + struct item *list = parse_item_list (self); + if (self->error) + { + assert (list == NULL); + return NULL; + } + if (!*self->cursor) + { + self->error = PARSE_ERROR_EOF; + item_free_list (list); + return NULL; + } + assert (*self->cursor == ']'); + self->cursor++; + return new_list (list); +} + +static struct item * +parse_item (struct tokenizer *self) +{ + char c; + switch ((c = *self->cursor++)) + { + case '[': return parse_list (self); + case '"': return parse_string (self); + default:; + } + + self->cursor--; + struct item *item = try_parse_number (self); + if (!item && !self->error) + item = parse_word (self); + return item; +} + +static struct item * +parse_item_list (struct tokenizer *self) +{ + struct item *head = NULL; + struct item **tail = &head; + + char c; + bool expected = true; + while ((c = *self->cursor) && c != ']') + { + if (isspace (c)) + { + self->cursor++; + expected = true; + continue; + } + else if (!expected) + { + self->error = PARSE_ERROR_UNEXPECTED_INPUT; + goto fail; + } + + if (!(*tail = parse_item (self))) + goto fail; + tail = &(*tail)->next; + expected = false; + } + return head; + +fail: + item_free_list (head); + return NULL; +} + +static struct item * +parse (const char *s, const char **error) +{ + struct tokenizer self = { .cursor = s, .error = PARSE_ERROR_OK }; + struct item *list = parse_item_list (&self); + if (!self.error && *self.cursor != '\0') + { + self.error = PARSE_ERROR_UNEXPECTED_INPUT; + item_free_list (list); + list = NULL; + } + +#define XX(x, y) y, + static const char *strings[PARSE_ERROR_COUNT] = + { PARSE_ERROR_TABLE (XX) }; +#undef XX + + static char error_buf[128]; + if (self.error && error) + { + snprintf (error_buf, sizeof error_buf, "at character %d: %s", + (int) (self.cursor - s) + 1, strings[self.error]); + *error = error_buf; + } + return list; +} + +// --- Runtime ----------------------------------------------------------------- + +// TODO: try to think of a _simple_ way to do preemptive multitasking + +struct context +{ + struct item *stack; ///< The current top of the stack + size_t stack_size; ///< Number of items on the stack + + size_t reduction_count; ///< # of function calls so far + size_t reduction_limit; ///< The hard limit on function calls + + char *error; ///< Error information + bool error_is_fatal; ///< Whether the error can be catched + bool memory_failure; ///< Memory allocation failure + + void *user_data; ///< User data +}; + +/// Internal handler for a function +typedef bool (*handler_fn) (struct context *); + +struct fn +{ + struct fn *next; ///< The next link in the chain + + handler_fn handler; ///< Internal C handler, or NULL + struct item *script; ///< Alternatively runtime code + char name[]; ///< The name of the function +}; + +struct fn *g_functions; ///< Maps words to functions + +static void +context_init (struct context *ctx) +{ + ctx->stack = NULL; + ctx->stack_size = 0; + + ctx->reduction_count = 0; + ctx->reduction_limit = 2000; + + ctx->error = NULL; + ctx->error_is_fatal = false; + ctx->memory_failure = false; + + ctx->user_data = NULL; +} + +static void +context_free (struct context *ctx) +{ + item_free_list (ctx->stack); + ctx->stack = NULL; + + free (ctx->error); + ctx->error = NULL; +} + +static bool +set_error (struct context *ctx, const char *format, ...) +{ + free (ctx->error); + + va_list ap; + va_start (ap, format); + ctx->error = strdup_vprintf (format, ap); + va_end (ap); + + if (!ctx->error) + ctx->memory_failure = true; + return false; +} + +static bool +push (struct context *ctx, struct item *item) +{ + // The `item' is typically a result from new_<type>(), thus when it is null, + // that function must have failed. This is a shortcut for convenience. + if (!item) + { + ctx->memory_failure = true; + return false; + } + + assert (item->next == NULL); + item->next = ctx->stack; + ctx->stack = item; + ctx->stack_size++; + return true; +} + +static bool +bump_reductions (struct context *ctx) +{ + if (++ctx->reduction_count >= ctx->reduction_limit) + { + ctx->error_is_fatal = true; + return set_error (ctx, "reduction limit reached"); + } + return true; +} + +static bool execute (struct context *, struct item *); + +static bool +call_function (struct context *ctx, const char *name) +{ + struct fn *iter; + for (iter = g_functions; iter; iter = iter->next) + if (!strcmp (name, iter->name)) + goto found; + return set_error (ctx, "unknown function: %s", name); + +found: + if (!bump_reductions (ctx)) + return false; + + if (iter->handler + ? iter->handler (ctx) + : execute (ctx, iter->script)) + return true; + + // In this case, `error' is NULL + if (ctx->memory_failure) + return false; + + // This creates some form of a stack trace + char *tmp = ctx->error; + ctx->error = NULL; + set_error (ctx, "%s -> %s", name, tmp); + free (tmp); + return false; +} + +static void +free_function (struct fn *fn) +{ + item_free_list (fn->script); + free (fn); +} + +static void +unregister_function (const char *name) +{ + for (struct fn **iter = &g_functions; *iter; iter = &(*iter)->next) + if (!strcmp ((*iter)->name, name)) + { + struct fn *tmp = *iter; + *iter = tmp->next; + free_function (tmp); + break; + } +} + +static struct fn * +prepend_new_fn (const char *name) +{ + struct fn *fn = calloc (1, sizeof *fn + strlen (name) + 1); + if (!fn) + return NULL; + + strcpy (fn->name, name); + fn->next = g_functions; + return g_functions = fn; +} + +static bool +register_handler (const char *name, handler_fn handler) +{ + unregister_function (name); + struct fn *fn = prepend_new_fn (name); + if (!fn) + return false; + fn->handler = handler; + return true; +} + +static bool +register_script (const char *name, struct item *script) +{ + unregister_function (name); + struct fn *fn = prepend_new_fn (name); + if (!fn) + return false; + fn->script = script; + return true; +} + +static bool +execute (struct context *ctx, struct item *script) +{ + for (; script; script = script->next) + { + if (script->type != ITEM_WORD) + { + if (!bump_reductions (ctx) + || !push (ctx, new_clone (script))) + return false; + } + else if (!call_function (ctx, get_word (script))) + return false; + } + return true; +} + +// --- Runtime library --------------------------------------------------------- + +#define defn(name) static bool name (struct context *ctx) + +#define check_stack(n) \ + if (ctx->stack_size < n) { \ + set_error (ctx, "stack underflow"); \ + return 0; \ + } + +inline static bool +check_stack_safe (struct context *ctx, size_t n) +{ + check_stack (n); + return true; +} + +static bool +check_type (struct context *ctx, const void *item_, enum item_type type) +{ + const struct item *item = item_; + if (item->type == type) + return true; + + return set_error (ctx, "invalid type: expected `%s', got `%s'", + item_type_to_str (type), item_type_to_str (item->type)); +} + +static struct item * +pop (struct context *ctx) +{ + check_stack (1); + struct item *top = ctx->stack; + ctx->stack = top->next; + top->next = NULL; + ctx->stack_size--; + return top; +} + +// - - Types - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +#define defn_is_type(name, item_type) \ + defn (fn_is_##name) { \ + check_stack (1); \ + struct item *top = pop (ctx); \ + push (ctx, new_integer (top->type == (item_type))); \ + item_free (top); \ + return true; \ + } + +defn_is_type (string, ITEM_STRING) +defn_is_type (word, ITEM_WORD) +defn_is_type (integer, ITEM_INTEGER) +defn_is_type (float, ITEM_FLOAT) +defn_is_type (list, ITEM_LIST) + +defn (fn_to_string) +{ + check_stack (1); + struct item *item = pop (ctx); + char *value; + + switch (item->type) + { + case ITEM_WORD: + item->type = ITEM_STRING; + case ITEM_STRING: + return push (ctx, item); + + case ITEM_FLOAT: + value = strdup_printf ("%Lf", get_float (item)); + break; + case ITEM_INTEGER: + value = strdup_printf ("%lld", get_integer (item)); + break; + + default: + set_error (ctx, "cannot convert `%s' to `%s'", + item_type_to_str (item->type), item_type_to_str (ITEM_STRING)); + item_free (item); + return false; + } + + item_free (item); + if (!value) + { + ctx->memory_failure = true; + return false; + } + + item = new_string (value, -1); + free (value); + return push (ctx, item); +} + +defn (fn_to_integer) +{ + check_stack (1); + struct item *item = pop (ctx); + long long value; + + switch (item->type) + { + case ITEM_INTEGER: + return push (ctx, item); + case ITEM_FLOAT: + value = get_float (item); + break; + + case ITEM_STRING: + { + char *end; + const char *s = get_string (item); + value = strtoll (s, &end, 10); + if (end != s && *s == '\0') + break; + + item_free (item); + return set_error (ctx, "integer conversion error"); + } + + default: + set_error (ctx, "cannot convert `%s' to `%s'", + item_type_to_str (item->type), item_type_to_str (ITEM_INTEGER)); + item_free (item); + return false; + } + + item_free (item); + return push (ctx, new_integer (value)); +} + +defn (fn_to_float) +{ + check_stack (1); + struct item *item = pop (ctx); + long double value; + + switch (item->type) + { + case ITEM_FLOAT: + return push (ctx, item); + case ITEM_INTEGER: + value = get_integer (item); + break; + + case ITEM_STRING: + { + char *end; + const char *s = get_string (item); + value = strtold (s, &end); + if (end != s && *s == '\0') + break; + + item_free (item); + return set_error (ctx, "float conversion error"); + } + + default: + set_error (ctx, "cannot convert `%s' to `%s'", + item_type_to_str (item->type), item_type_to_str (ITEM_FLOAT)); + item_free (item); + return false; + } + + item_free (item); + return push (ctx, new_float (value)); +} + +// - - Miscellaneous - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +defn (fn_length) +{ + check_stack (1); + struct item *item = pop (ctx); + bool success = true; + switch (item->type) + { + case ITEM_STRING: + success = push (ctx, new_integer (((struct item_string *) item)->len)); + break; + case ITEM_LIST: + { + long long length = 0; + struct item *iter; + for (iter = get_list (item); iter; iter = iter->next) + length++; + success = push (ctx, new_integer (length)); + break; + } + default: + success = set_error (ctx, "invalid type"); + } + item_free (item); + return success; +} + +// - - Stack operations - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +defn (fn_dup) +{ + check_stack (1); + return push (ctx, new_clone (ctx->stack)); +} + +defn (fn_drop) +{ + check_stack (1); + item_free (pop (ctx)); + return true; +} + +defn (fn_swap) +{ + check_stack (2); + struct item *second = pop (ctx), *first = pop (ctx); + return push (ctx, second) && push (ctx, first); +} + +defn (fn_call) +{ + check_stack (1); + struct item *script = pop (ctx); + bool success = check_type (ctx, script, ITEM_LIST) + && execute (ctx, get_list (script)); + item_free (script); + return success; +} + +defn (fn_dip) +{ + check_stack (2); + struct item *script = pop (ctx); + struct item *item = pop (ctx); + bool success = check_type (ctx, script, ITEM_LIST) + && execute (ctx, get_list (script)); + item_free (script); + if (!success) + { + item_free (item); + return false; + } + return push (ctx, item); +} + +defn (fn_unit) +{ + check_stack (1); + struct item *item = pop (ctx); + return push (ctx, new_list (item)); +} + +defn (fn_cons) +{ + check_stack (2); + struct item *list = pop (ctx); + struct item *item = pop (ctx); + if (!check_type (ctx, list, ITEM_LIST)) + { + item_free (list); + item_free (item); + return false; + } + item->next = get_list (list); + ((struct item_list *) list)->head = item; + return push (ctx, list); +} + +defn (fn_cat) +{ + check_stack (2); + struct item *scnd = pop (ctx); + struct item *frst = pop (ctx); + if (!check_type (ctx, frst, ITEM_LIST) + || !check_type (ctx, scnd, ITEM_LIST)) + { + item_free (frst); + item_free (scnd); + return false; + } + + // XXX: we shouldn't have to do this in O(n) + struct item **tail = &((struct item_list *) frst)->head; + while (*tail) + tail = &(*tail)->next; + *tail = get_list (scnd); + + ((struct item_list *) scnd)->head = NULL; + item_free (scnd); + return push (ctx, frst); +} + +defn (fn_uncons) +{ + check_stack (1); + struct item *list = pop (ctx); + if (!check_type (ctx, list, ITEM_LIST)) + goto fail; + struct item *first = get_list (list); + if (!first) + { + set_error (ctx, "list is empty"); + goto fail; + } + ((struct item_list *) list)->head = first->next; + first->next = NULL; + return push (ctx, first) && push (ctx, list); +fail: + item_free (list); + return false; +} + +// - - Logical - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +to_boolean (struct context *ctx, struct item *item, bool *ok) +{ + switch (item->type) + { + case ITEM_STRING: + return *get_string (item) != '\0'; + case ITEM_INTEGER: + return get_integer (item) != 0; + case ITEM_FLOAT: + return get_float (item) != 0.; + default: + return (*ok = set_error (ctx, "cannot convert `%s' to boolean", + item_type_to_str (item->type))); + } +} + +defn (fn_not) +{ + check_stack (1); + struct item *item = pop (ctx); + bool ok = true; + bool result = !to_boolean (ctx, item, &ok); + item_free (item); + return ok && push (ctx, new_integer (result)); +} + +defn (fn_and) +{ + check_stack (2); + struct item *op1 = pop (ctx); + struct item *op2 = pop (ctx); + bool ok = true; + bool result = to_boolean (ctx, op1, &ok) && to_boolean (ctx, op2, &ok); + item_free (op1); + item_free (op2); + return ok && push (ctx, new_integer (result)); +} + +defn (fn_or) +{ + check_stack (2); + struct item *op1 = pop (ctx); + struct item *op2 = pop (ctx); + bool ok = true; + bool result = to_boolean (ctx, op1, &ok) + || !ok || to_boolean (ctx, op2, &ok); + item_free (op1); + item_free (op2); + return ok && push (ctx, new_integer (result)); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +defn (fn_if) +{ + check_stack (3); + struct item *else_ = pop (ctx); + struct item *then_ = pop (ctx); + struct item *cond_ = pop (ctx); + + bool ok = true; + bool condition = to_boolean (ctx, cond_, &ok); + item_free (cond_); + + bool success = false; + if (ok + && check_type (ctx, then_, ITEM_LIST) + && check_type (ctx, else_, ITEM_LIST)) + success = execute (ctx, condition + ? get_list (then_) + : get_list (else_)); + + item_free (then_); + item_free (else_); + return success; +} + +defn (fn_try) +{ + check_stack (2); + struct item *catch = pop (ctx); + struct item *try = pop (ctx); + bool success = false; + if (!check_type (ctx, try, ITEM_LIST) + || !check_type (ctx, catch, ITEM_LIST)) + goto fail; + + if (!execute (ctx, get_list (try))) + { + if (ctx->memory_failure || ctx->error_is_fatal) + goto fail; + + success = push (ctx, new_string (ctx->error, -1)); + free (ctx->error); + ctx->error = NULL; + + if (success) + success = execute (ctx, get_list (catch)); + } + +fail: + item_free (try); + item_free (catch); + return success; +} + +defn (fn_map) +{ + check_stack (2); + struct item *fn = pop (ctx); + struct item *list = pop (ctx); + if (!check_type (ctx, fn, ITEM_LIST) + || !check_type (ctx, list, ITEM_LIST)) + { + item_free (fn); + item_free (list); + return false; + } + + bool success = false; + struct item *result = NULL, **tail = &result; + for (struct item *iter = get_list (list); iter; iter = iter->next) + { + if (!push (ctx, new_clone (iter)) + || !execute (ctx, get_list (fn)) + || !check_stack_safe (ctx, 1)) + goto fail; + + struct item *item = pop (ctx); + *tail = item; + tail = &item->next; + } + success = true; + +fail: + set_list (list, result); + item_free (fn); + if (!success) + { + item_free (list); + return false; + } + return push (ctx, list); +} + +defn (fn_filter) +{ + check_stack (2); + struct item *fn = pop (ctx); + struct item *list = pop (ctx); + if (!check_type (ctx, fn, ITEM_LIST) + || !check_type (ctx, list, ITEM_LIST)) + { + item_free (fn); + item_free (list); + return false; + } + + bool success = false; + bool ok = true; + struct item *result = NULL, **tail = &result; + for (struct item *iter = get_list (list); iter; iter = iter->next) + { + if (!push (ctx, new_clone (iter)) + || !execute (ctx, get_list (fn)) + || !check_stack_safe (ctx, 1)) + goto fail; + + struct item *item = pop (ctx); + bool survived = to_boolean (ctx, item, &ok); + item_free (item); + if (!ok) + goto fail; + if (!survived) + continue; + + if (!(item = new_clone (iter))) + goto fail; + *tail = item; + tail = &item->next; + } + success = true; + +fail: + set_list (list, result); + item_free (fn); + if (!success) + { + item_free (list); + return false; + } + return push (ctx, list); +} + +defn (fn_fold) +{ + check_stack (3); + struct item *op = pop (ctx); + struct item *null = pop (ctx); + struct item *list = pop (ctx); + bool success = false; + if (!check_type (ctx, op, ITEM_LIST) + || !check_type (ctx, list, ITEM_LIST)) + { + item_free (null); + goto fail; + } + + push (ctx, null); + for (struct item *iter = get_list (list); iter; iter = iter->next) + if (!push (ctx, new_clone (iter)) + || !execute (ctx, get_list (op))) + goto fail; + success = true; + +fail: + item_free (op); + item_free (list); + return success; +} + +defn (fn_each) +{ + check_stack (2); + struct item *op = pop (ctx); + struct item *list = pop (ctx); + bool success = false; + if (!check_type (ctx, op, ITEM_LIST) + || !check_type (ctx, list, ITEM_LIST)) + goto fail; + + for (struct item *iter = get_list (list); iter; iter = iter->next) + if (!push (ctx, new_clone (iter)) + || !execute (ctx, get_list (op))) + goto fail; + success = true; + +fail: + item_free (op); + item_free (list); + return success; +} + +// - - Arithmetic - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// XXX: why not a `struct item_string *` argument? +static bool +push_repeated_string (struct context *ctx, struct item *op1, struct item *op2) +{ + struct item_string *string = (struct item_string *) op1; + struct item_integer *repeat = (struct item_integer *) op2; + assert (string->type == ITEM_STRING); + assert (repeat->type == ITEM_INTEGER); + + if (repeat->value < 0) + return set_error (ctx, "cannot multiply a string by a negative value"); + + char *buf = NULL; + size_t len = string->len * repeat->value; + if (len < string->len && repeat->value != 0) + goto allocation_fail; + + buf = malloc (len); + if (!buf) + goto allocation_fail; + + for (size_t i = 0; i < len; i += string->len) + memcpy (buf + i, string->value, string->len); + struct item *item = new_string (buf, len); + free (buf); + return push (ctx, item); + +allocation_fail: + ctx->memory_failure = true; + return false; +} + +defn (fn_times) +{ + check_stack (2); + struct item *op2 = pop (ctx); + struct item *op1 = pop (ctx); + + bool ok; + if (op1->type == ITEM_INTEGER && op2->type == ITEM_INTEGER) + ok = push (ctx, new_integer (get_integer (op1) * get_integer (op2))); + else if (op1->type == ITEM_INTEGER && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (get_integer (op1) * get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (get_float (op1) * get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_INTEGER) + ok = push (ctx, new_float (get_float (op1) * get_integer (op2))); + else if (op1->type == ITEM_INTEGER && op2->type == ITEM_STRING) + ok = push_repeated_string (ctx, op2, op1); + else if (op1->type == ITEM_STRING && op2->type == ITEM_INTEGER) + ok = push_repeated_string (ctx, op1, op2); + else + ok = set_error (ctx, "cannot multiply `%s' and `%s'", + item_type_to_str (op1->type), item_type_to_str (op2->type)); + + item_free (op1); + item_free (op2); + return ok; +} + +defn (fn_pow) +{ + check_stack (2); + struct item *op2 = pop (ctx); + struct item *op1 = pop (ctx); + + bool ok; + if (op1->type == ITEM_INTEGER && op2->type == ITEM_INTEGER) + // TODO: implement this properly, outputting an integer + ok = push (ctx, new_float (powl (get_integer (op1), get_integer (op2)))); + else if (op1->type == ITEM_INTEGER && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (powl (get_integer (op1), get_float (op2)))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (powl (get_float (op1), get_float (op2)))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_INTEGER) + ok = push (ctx, new_float (powl (get_float (op1), get_integer (op2)))); + else + ok = set_error (ctx, "cannot exponentiate `%s' and `%s'", + item_type_to_str (op1->type), item_type_to_str (op2->type)); + + item_free (op1); + item_free (op2); + return ok; +} + +defn (fn_div) +{ + check_stack (2); + struct item *op2 = pop (ctx); + struct item *op1 = pop (ctx); + + bool ok; + if (op1->type == ITEM_INTEGER && op2->type == ITEM_INTEGER) + { + if (get_integer (op2) == 0) + ok = set_error (ctx, "division by zero"); + else + ok = push (ctx, new_integer (get_integer (op1) / get_integer (op2))); + } + else if (op1->type == ITEM_INTEGER && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (get_integer (op1) / get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (get_float (op1) / get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_INTEGER) + ok = push (ctx, new_float (get_float (op1) / get_integer (op2))); + else + ok = set_error (ctx, "cannot divide `%s' and `%s'", + item_type_to_str (op1->type), item_type_to_str (op2->type)); + + item_free (op1); + item_free (op2); + return ok; +} + +defn (fn_mod) +{ + check_stack (2); + struct item *op2 = pop (ctx); + struct item *op1 = pop (ctx); + + bool ok; + if (op1->type == ITEM_INTEGER && op2->type == ITEM_INTEGER) + { + if (get_integer (op2) == 0) + ok = set_error (ctx, "division by zero"); + else + ok = push (ctx, new_integer (get_integer (op1) % get_integer (op2))); + } + else if (op1->type == ITEM_INTEGER && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (fmodl (get_integer (op1), get_float (op2)))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (fmodl (get_float (op1), get_float (op2)))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_INTEGER) + ok = push (ctx, new_float (fmodl (get_float (op1), get_integer (op2)))); + else + ok = set_error (ctx, "cannot divide `%s' and `%s'", + item_type_to_str (op1->type), item_type_to_str (op2->type)); + + item_free (op1); + item_free (op2); + return ok; +} + +static bool +push_concatenated_string (struct context *ctx, + struct item *op1, struct item *op2) +{ + struct item_string *s1 = (struct item_string *) op1; + struct item_string *s2 = (struct item_string *) op2; + assert (s1->type == ITEM_STRING); + assert (s2->type == ITEM_STRING); + + char *buf = NULL; + size_t len = s1->len + s2->len; + if (len < s1->len || len < s2->len) + goto allocation_fail; + + buf = malloc (len); + if (!buf) + goto allocation_fail; + + memcpy (buf, s1->value, s1->len); + memcpy (buf + s1->len, s2->value, s2->len); + struct item *item = new_string (buf, len); + free (buf); + return push (ctx, item); + +allocation_fail: + ctx->memory_failure = true; + return false; + +} + +defn (fn_plus) +{ + check_stack (2); + struct item *op2 = pop (ctx); + struct item *op1 = pop (ctx); + + bool ok; + if (op1->type == ITEM_INTEGER && op2->type == ITEM_INTEGER) + ok = push (ctx, new_integer (get_integer (op1) + get_integer (op2))); + else if (op1->type == ITEM_INTEGER && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (get_integer (op1) + get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (get_float (op1) + get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_INTEGER) + ok = push (ctx, new_float (get_float (op1) + get_integer (op2))); + else if (op1->type == ITEM_STRING && op2->type == ITEM_STRING) + ok = push_concatenated_string (ctx, op1, op2); + else + ok = set_error (ctx, "cannot add `%s' and `%s'", + item_type_to_str (op1->type), item_type_to_str (op2->type)); + + item_free (op1); + item_free (op2); + return ok; +} + +defn (fn_minus) +{ + check_stack (2); + struct item *op2 = pop (ctx); + struct item *op1 = pop (ctx); + + bool ok; + if (op1->type == ITEM_INTEGER && op2->type == ITEM_INTEGER) + ok = push (ctx, new_integer (get_integer (op1) - get_integer (op2))); + else if (op1->type == ITEM_INTEGER && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (get_integer (op1) - get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_FLOAT) + ok = push (ctx, new_float (get_float (op1) - get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_INTEGER) + ok = push (ctx, new_float (get_float (op1) - get_integer (op2))); + else + ok = set_error (ctx, "cannot subtract `%s' and `%s'", + item_type_to_str (op1->type), item_type_to_str (op2->type)); + + item_free (op1); + item_free (op2); + return ok; +} + +// - - Comparison - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +compare_strings (struct item_string *s1, struct item_string *s2) +{ + // XXX: not entirely correct wrt. null bytes + size_t len = (s1->len < s2->len ? s1->len : s2->len) + 1; + return memcmp (s1->value, s2->value, len); +} + +static bool compare_lists (struct item *, struct item *); + +static bool +compare_list_items (struct item *op1, struct item *op2) +{ + if (op1->type != op2->type) + return false; + + switch (op1->type) + { + case ITEM_STRING: + case ITEM_WORD: + return !compare_strings ((struct item_string *) op1, + (struct item_string *) op2); + case ITEM_FLOAT: + return get_float (op1) == get_float (op2); + case ITEM_INTEGER: + return get_integer (op1) == get_integer (op2); + case ITEM_LIST: + return compare_lists (get_list (op1), get_list (op2)); + } + abort (); +} + +static bool +compare_lists (struct item *op1, struct item *op2) +{ + while (op1 && op2) + { + if (!compare_list_items (op1, op2)) + return false; + + op1 = op1->next; + op2 = op2->next; + } + return !op1 && !op2; +} + +defn (fn_eq) +{ + check_stack (2); + struct item *op2 = pop (ctx); + struct item *op1 = pop (ctx); + + bool ok; + if (op1->type == ITEM_INTEGER && op2->type == ITEM_INTEGER) + ok = push (ctx, new_integer (get_integer (op1) == get_integer (op2))); + else if (op1->type == ITEM_INTEGER && op2->type == ITEM_FLOAT) + ok = push (ctx, new_integer (get_integer (op1) == get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_FLOAT) + ok = push (ctx, new_integer (get_float (op1) == get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_INTEGER) + ok = push (ctx, new_integer (get_float (op1) == get_integer (op2))); + else if (op1->type == ITEM_LIST && op2->type == ITEM_LIST) + ok = push (ctx, new_integer (compare_lists + (get_list (op1), get_list (op2)))); + else if (op1->type == ITEM_STRING && op2->type == ITEM_STRING) + ok = push (ctx, new_integer (compare_strings + ((struct item_string *)(op1), (struct item_string *)(op2)) == 0)); + else + ok = set_error (ctx, "cannot compare `%s' and `%s'", + item_type_to_str (op1->type), item_type_to_str (op2->type)); + + item_free (op1); + item_free (op2); + return ok; +} + +defn (fn_lt) +{ + check_stack (2); + struct item *op2 = pop (ctx); + struct item *op1 = pop (ctx); + + bool ok; + if (op1->type == ITEM_INTEGER && op2->type == ITEM_INTEGER) + ok = push (ctx, new_integer (get_integer (op1) < get_integer (op2))); + else if (op1->type == ITEM_INTEGER && op2->type == ITEM_FLOAT) + ok = push (ctx, new_integer (get_integer (op1) < get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_FLOAT) + ok = push (ctx, new_integer (get_float (op1) < get_float (op2))); + else if (op1->type == ITEM_FLOAT && op2->type == ITEM_INTEGER) + ok = push (ctx, new_integer (get_float (op1) < get_integer (op2))); + else if (op1->type == ITEM_STRING && op2->type == ITEM_STRING) + ok = push (ctx, new_integer (compare_strings + ((struct item_string *)(op1), (struct item_string *)(op2)) < 0)); + else + ok = set_error (ctx, "cannot compare `%s' and `%s'", + item_type_to_str (op1->type), item_type_to_str (op2->type)); + + item_free (op1); + item_free (op2); + return ok; +} + +// - - Utilities - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +defn (fn_rand) +{ + return push (ctx, new_float ((long double) rand () + / ((long double) RAND_MAX + 1))); +} + +defn (fn_time) +{ + return push (ctx, new_integer (time (NULL))); +} + +// XXX: this is a bit too constrained; combines strftime() with gmtime() +defn (fn_strftime) +{ + check_stack (2); + struct item *format = pop (ctx); + struct item *time_ = pop (ctx); + bool success = false; + if (!check_type (ctx, time_, ITEM_INTEGER) + || !check_type (ctx, format, ITEM_STRING)) + goto fail; + + if (get_integer (time_) < 0) + { + set_error (ctx, "invalid time value"); + goto fail; + } + + char buf[128]; + time_t time__ = get_integer (time_); + struct tm tm; + gmtime_r (&time__, &tm); + buf[strftime (buf, sizeof buf, get_string (format), &tm)] = '\0'; + success = push (ctx, new_string (buf, -1)); + +fail: + item_free (time_); + item_free (format); + return success; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void item_list_to_str (const struct item *, struct buffer *); + +static void +string_to_str (const struct item_string *string, struct buffer *buf) +{ + buffer_append_c (buf, '"'); + for (size_t i = 0; i < string->len; i++) + { + char c = string->value[i]; + if (c == '\n') buffer_append (buf, "\\n", 2); + else if (c == '\r') buffer_append (buf, "\\r", 2); + else if (c == '\t') buffer_append (buf, "\\t", 2); + else if (!isprint (c)) + { + char tmp[8]; + snprintf (tmp, sizeof tmp, "\\x%02x", (unsigned char) c); + buffer_append (buf, tmp, strlen (tmp)); + } + else if (c == '\\') buffer_append (buf, "\\\\", 2); + else if (c == '"') buffer_append (buf, "\\\"", 2); + else buffer_append_c (buf, c); + } + buffer_append_c (buf, '"'); +} + +static void +item_to_str (const struct item *item, struct buffer *buf) +{ + switch (item->type) + { + char *x; + case ITEM_STRING: + string_to_str ((struct item_string *) item, buf); + break; + case ITEM_WORD: + { + struct item_word *word = (struct item_word *) item; + buffer_append (buf, word->value, word->len); + break; + } + case ITEM_INTEGER: + if (!(x = strdup_printf ("%lld", get_integer (item)))) + goto alloc_failure; + buffer_append (buf, x, strlen (x)); + free (x); + break; + case ITEM_FLOAT: + if (!(x = strdup_printf ("%Lf", get_float (item)))) + goto alloc_failure; + buffer_append (buf, x, strlen (x)); + free (x); + break; + case ITEM_LIST: + buffer_append_c (buf, '['); + item_list_to_str (get_list (item), buf); + buffer_append_c (buf, ']'); + break; + } + return; + +alloc_failure: + // This is a bit hackish but it simplifies stuff + buf->memory_failure = true; + free (buf->s); + buf->s = NULL; +} + +static void +item_list_to_str (const struct item *script, struct buffer *buf) +{ + if (!script) + return; + + item_to_str (script, buf); + while ((script = script->next)) + { + buffer_append_c (buf, ' '); + item_to_str (script, buf); + } +} + +// --- IRC protocol ------------------------------------------------------------ + +struct message +{ + char *prefix; ///< Message prefix + char *command; ///< IRC command + char *params[16]; ///< Command parameters (0-terminated) + size_t n_params; ///< Number of parameters present +}; + +inline static char * +cut_word (char **s) +{ + char *start = *s, *end = *s + strcspn (*s, " "); + *s = end + strspn (end, " "); + *end = '\0'; + return start; +} + +static bool +parse_message (char *s, struct message *msg) +{ + memset (msg, 0, sizeof *msg); + + // Ignore IRC 3.2 message tags, if present + if (*s == '@') + { + s += strcspn (s, " "); + s += strspn (s, " "); + } + + // Prefix + if (*s == ':') + msg->prefix = cut_word (&s) + 1; + + // Command + if (!*(msg->command = cut_word (&s))) + return false; + + // Parameters + while (*s) + { + size_t n = msg->n_params++; + if (msg->n_params >= N_ELEMENTS (msg->params)) + return false; + if (*s == ':') + { + msg->params[n] = ++s; + break; + } + msg->params[n] = cut_word (&s); + } + return true; +} + +static struct message * +read_message (void) +{ + static bool discard = false; + static char buf[1025]; + static struct message msg; + + bool discard_this; + do + { + if (!fgets (buf, sizeof buf, stdin)) + return NULL; + size_t len = strlen (buf); + + // Just to be on the safe side, if the line overflows our buffer, + // ignore everything up until the next line. + discard_this = discard; + if (len >= 2 && !strcmp (buf + len - 2, "\r\n")) + { + buf[len -= 2] = '\0'; + discard = false; + } + else + discard = true; + } + // Invalid messages are silently ignored + while (discard_this || !parse_message (buf, &msg)); + return &msg; +} + +// --- Interfacing with the bot ------------------------------------------------ + +#define BOT_PRINT "XB print :script: " + +static const char * +get_config (const char *key) +{ + printf ("XB get_config :%s\r\n", key); + struct message *msg = read_message (); + if (!msg || msg->n_params <= 0) + exit (EXIT_FAILURE); + return msg->params[0]; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// TODO: implement more functions; try to avoid writing them in C + +static bool +init_runtime_library_scripts (void) +{ + bool ok = true; + + // It's much cheaper (and more fun) to define functions in terms of other + // ones. The "unit tests" serve a secondary purpose of showing the usage. + struct script + { + const char *name; ///< Name of the function + const char *definition; ///< The defining script + const char *unit_test; ///< Trivial unit test, must return 1 + } + scripts[] = + { + { "nip", "swap drop", "1 2 nip 2 =" }, + { "over", "[dup] dip swap", "1 2 over nip nip 1 =" }, + { "swons", "swap cons", "[2] 1 swons [1 2] =" }, + { "first", "uncons drop", "[1 2 3] first 1 =" }, + { "rest", "uncons swap drop", "[1 2 3] rest [2 3] =" }, + { "reverse", "[] swap [swap cons] each", "[1 2] reverse [2 1] =" }, + { "curry", "cons", "1 2 [+] curry call 3 =" }, + + { "xor", "not swap not + 1 =", "1 1 xor 0 =" }, + { "min", "over over < [drop] [nip] if", "1 2 min 1 =" }, + { "max", "over over > [drop] [nip] if", "1 2 max 2 =" }, + + { "all?", "[and] cat 1 swap fold", "[3 4 5] [> 3] all? 0 =" }, + { "any?", "[or] cat 0 swap fold", "[3 4 5] [> 3] any? 1 =" }, + + { ">", "swap <", "1 2 > 0 =" }, + { "!=", "= not", "1 2 != 1 =" }, + { "<=", "> not", "1 2 <= 1 =" }, + { ">=", "< not", "1 2 >= 0 =" }, + + // XXX: this is a bit crazy and does not work with an empty list + { "join", "[uncons] dip swap [[dup] dip swap [+ +] dip] each drop", + "[1 2 3] [>string] map \" -> \" join \"1 -> 2 -> 3\" =" }, + }; + + for (size_t i = 0; i < N_ELEMENTS (scripts); i++) + { + const char *error = NULL; + struct item *script = parse (scripts[i].definition, &error); + if (error) + { + printf (BOT_PRINT "error parsing internal script `%s': %s\r\n", + scripts[i].definition, error); + ok = false; + } + else + ok &= register_script (scripts[i].name, script); + } + + struct context ctx; + for (size_t i = 0; i < N_ELEMENTS (scripts); i++) + { + const char *error = NULL; + struct item *script = parse (scripts[i].unit_test, &error); + if (error) + { + printf (BOT_PRINT "error parsing unit test for `%s': %s\r\n", + scripts[i].name, error); + ok = false; + continue; + } + context_init (&ctx); + execute (&ctx, script); + item_free_list (script); + + const char *failure = NULL; + if (ctx.memory_failure) + failure = "memory allocation failure"; + else if (ctx.error) + failure = ctx.error; + else if (ctx.stack_size != 1) + failure = "too many results on the stack"; + else if (ctx.stack->type != ITEM_INTEGER) + failure = "result is not an integer"; + else if (get_integer (ctx.stack) != 1) + failure = "wrong test result"; + if (failure) + { + printf (BOT_PRINT "error executing unit test for `%s': %s\r\n", + scripts[i].name, failure); + ok = false; + } + context_free (&ctx); + } + return ok; +} + +static bool +init_runtime_library (void) +{ + bool ok = true; + + // Type detection + ok &= register_handler ("string?", fn_is_string); + ok &= register_handler ("word?", fn_is_word); + ok &= register_handler ("integer?", fn_is_integer); + ok &= register_handler ("float?", fn_is_float); + ok &= register_handler ("list?", fn_is_list); + + // Type conversion + ok &= register_handler (">string", fn_to_string); + ok &= register_handler (">integer", fn_to_integer); + ok &= register_handler (">float", fn_to_float); + + // Miscellaneous + ok &= register_handler ("length", fn_length); + + // Basic stack manipulation + ok &= register_handler ("dup", fn_dup); + ok &= register_handler ("drop", fn_drop); + ok &= register_handler ("swap", fn_swap); + + // Calling stuff + ok &= register_handler ("call", fn_call); + ok &= register_handler ("dip", fn_dip); + + // Control flow + ok &= register_handler ("if", fn_if); + ok &= register_handler ("try", fn_try); + + // List processing + ok &= register_handler ("map", fn_map); + ok &= register_handler ("filter", fn_filter); + ok &= register_handler ("fold", fn_fold); + ok &= register_handler ("each", fn_each); + + // List manipulation + ok &= register_handler ("unit", fn_unit); + ok &= register_handler ("cons", fn_cons); + ok &= register_handler ("cat", fn_cat); + ok &= register_handler ("uncons", fn_uncons); + + // Arithmetic operations + ok &= register_handler ("+", fn_plus); + ok &= register_handler ("-", fn_minus); + ok &= register_handler ("*", fn_times); + ok &= register_handler ("^", fn_pow); + ok &= register_handler ("/", fn_div); + ok &= register_handler ("%", fn_mod); + + // Comparison + ok &= register_handler ("=", fn_eq); + ok &= register_handler ("<", fn_lt); + + // Logical operations + ok &= register_handler ("not", fn_not); + ok &= register_handler ("and", fn_and); + ok &= register_handler ("or", fn_or); + + // Utilities + ok &= register_handler ("rand", fn_rand); + ok &= register_handler ("time", fn_time); + ok &= register_handler ("strftime", fn_strftime); + + ok &= init_runtime_library_scripts (); + return ok; +} + +static void +free_runtime_library (void) +{ + struct fn *next, *iter; + for (iter = g_functions; iter; iter = next) + { + next = iter->next; + free_function (iter); + } +} + +// --- Function database ------------------------------------------------------- + +// TODO: a global variable storing the various procedures (db) +// XXX: defining procedures would ideally need some kind of an ACL + +static void +read_db (void) +{ + // TODO +} + +static void +write_db (void) +{ + // TODO +} + +// --- Main -------------------------------------------------------------------- + +static char *g_prefix; + +struct user_info +{ + char *ctx; ///< Context: channel or user + char *ctx_quote; ///< Reply quotation +}; + +defn (fn_dot) +{ + check_stack (1); + struct item *item = pop (ctx); + struct user_info *info = ctx->user_data; + + struct buffer buf = BUFFER_INITIALIZER; + item_to_str (item, &buf); + item_free (item); + buffer_append_c (&buf, '\0'); + if (buf.memory_failure) + { + ctx->memory_failure = true; + return false; + } + + if (buf.len > 255) + buf.s[255] = '\0'; + + printf ("PRIVMSG %s :%s%s\r\n", info->ctx, info->ctx_quote, buf.s); + free (buf.s); + return true; +} + +static void +process_message (struct message *msg) +{ + if (!msg->prefix + || strcasecmp (msg->command, "PRIVMSG") + || msg->n_params < 2) + return; + char *line = msg->params[1]; + + // Filter out only our commands + size_t prefix_len = strlen (g_prefix); + if (strncmp (line, g_prefix, prefix_len)) + return; + line += prefix_len; + + char *command = cut_word (&line); + if (strcasecmp (command, "script")) + return; + + // Retrieve information on how to respond back + char *msg_ctx = msg->prefix, *x; + if ((x = strchr (msg_ctx, '!'))) + *x = '\0'; + + char *msg_ctx_quote; + if (strchr ("#+&!", *msg->params[0])) + { + msg_ctx_quote = strdup_printf ("%s: ", msg_ctx); + msg_ctx = msg->params[0]; + } + else + msg_ctx_quote = strdup (""); + + if (!msg_ctx_quote) + { + printf (BOT_PRINT "%s\r\n", "memory allocation failure"); + return; + } + + struct user_info info; + info.ctx = msg_ctx; + info.ctx_quote = msg_ctx_quote; + + // Finally parse and execute the macro + const char *error = NULL; + struct item *script = parse (line, &error); + if (error) + { + printf ("PRIVMSG %s :%s%s: %s\r\n", + msg_ctx, msg_ctx_quote, "parse error", error); + goto end; + } + + struct context ctx; + context_init (&ctx); + ctx.user_data = &info; + execute (&ctx, script); + item_free_list (script); + + const char *failure = NULL; + if (ctx.memory_failure) + failure = "memory allocation failure"; + else if (ctx.error) + failure = ctx.error; + if (failure) + printf ("PRIVMSG %s :%s%s: %s\r\n", + msg_ctx, msg_ctx_quote, "runtime error", failure); + context_free (&ctx); +end: + free (msg_ctx_quote); +} + +int +main (int argc, char *argv[]) +{ + freopen (NULL, "rb", stdin); setvbuf (stdin, NULL, _IOLBF, BUFSIZ); + freopen (NULL, "wb", stdout); setvbuf (stdout, NULL, _IOLBF, BUFSIZ); + + struct rlimit limit = + { + .rlim_cur = ADDRESS_SPACE_LIMIT, + .rlim_max = ADDRESS_SPACE_LIMIT + }; + + // Lower the memory limits to something sensible to prevent abuse + (void) setrlimit (RLIMIT_AS, &limit); + + read_db (); + if (!init_runtime_library () + || !register_handler (".", fn_dot)) + printf (BOT_PRINT "%s\r\n", "runtime library initialization failed"); + + g_prefix = strdup (get_config ("prefix")); + printf ("XB register\r\n"); + struct message *msg; + while ((msg = read_message ())) + process_message (msg); + + free_runtime_library (); + free (g_prefix); + return 0; +} + diff --git a/plugins/xB/seen b/plugins/xB/seen new file mode 100755 index 0000000..da43488 --- /dev/null +++ b/plugins/xB/seen @@ -0,0 +1,160 @@ +#!/usr/bin/env lua +-- +-- xB seen plugin +-- +-- Copyright 2016 Přemysl Eric Janouch <p@janouch.name> +-- See the file LICENSE for licensing information. +-- + +function parse (line) + local msg = { params = {} } + line = line:match ("[^\r]*") + for start, word in line:gmatch ("()([^ ]+)") do + local colon = word:match ("^:(.*)") + if start == 1 and colon then + msg.prefix = colon + elseif not msg.command then + msg.command = word + elseif colon then + table.insert (msg.params, line:sub (start + 1)) + break + elseif start ~= #line then + table.insert (msg.params, word) + end + end + return msg +end + +function get_config (name) + io.write ("XB get_config :", name, "\r\n") + return parse (io.read ()).params[1] +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +io.output ():setvbuf ('line') +local prefix = get_config ('prefix') +io.write ("XB register\r\n") + +local db = {} +local db_filename = "seen.db" +local db_garbage = 0 + +function remember (who, where, when, what) + if not db[who] then db[who] = {} end + if db[who][where] then db_garbage = db_garbage + 1 end + db[who][where] = { tonumber (when), what } +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +local db_file, e = io.open (db_filename, "a+") +if not db_file then error ("cannot open database: " .. e, 0) end + +function db_store (who, where, when, what) + db_file:write (string.format + (":%s %s %s %s :%s\n", who, "PRIVMSG", where, when, what)) +end + +function db_compact () + db_file:close () + + -- Unfortunately, default Lua doesn't have anything like mkstemp() + local db_tmpname = db_filename .. "." .. os.time () + db_file, e = io.open (db_tmpname, "a+") + if not db_file then error ("cannot save database: " .. e, 0) end + + for who, places in pairs (db) do + for where, data in pairs (places) do + db_store (who, where, data[1], data[2]) + end + end + db_file:flush () + + local ok, e = os.rename (db_tmpname, db_filename) + if not ok then error ("cannot save database: " .. e, 0) end + db_garbage = 0 +end + +for line in db_file:lines () do + local msg = parse (line) + remember (msg.prefix, table.unpack (msg.params)) +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function seen (who, where, args) + local respond = function (...) + local privmsg = function (target, ...) + io.write ("PRIVMSG ", target, " :", table.concat { ... }, "\r\n") + end + if where:match ("^[#&!+]") then + privmsg (where, who, ": ", ...) + else + privmsg (who, ...) + end + end + + local whom, e, garbage = args:match ("^(%S+)()%s*(.*)") + if not whom or #garbage ~= 0 then + return respond ("usage: <name>") + elseif who:lower () == whom:lower () then + return respond ("I can see you right now.") + end + + local top = {} + -- That is, * acts like a wildcard, otherwise everything is escaped + local pattern = "^" .. whom:gsub ("[%^%$%(%)%%%.%[%]%+%-%?]", "%%%0") + :gsub ("%*", ".*"):lower () .. "$" + for name, places in pairs (db) do + if places[where] and name:lower ():match (pattern) then + local when, what = table.unpack (places[where]) + table.insert (top, { name = name, when = when, what = what }) + end + end + if #top == 0 then + return respond ("I have not seen \x02" .. whom .. "\x02 here.") + end + + -- Get all matching nicknames ordered from the most recently active + -- and make the list case insensitive (remove older duplicates) + table.sort (top, function (a, b) return a.when > b.when end) + for i = #top, 2, -1 do + if top[i - 1].name:lower () == top[i].name:lower () then + table.remove (top, i) + end + end + + -- Hopefully the formatting mess will disrupt highlights in clients + for i = 1, math.min (#top, 3) do + local name = top[i].name:gsub ("^.", "%0\x02\x02") + respond (string.format ("\x02%s\x02 -> %s -> %s", + name, os.date ("%c", top[i].when), top[i].what)) + end +end + +function handle (msg) + local who = msg.prefix:match ("^[^!@]*") + local where, what = table.unpack (msg.params) + local when = os.time () + + local what_log = what:gsub ("^\x01ACTION", "*"):gsub ("\x01$", "") + remember (who, where, when, what_log) + db_store (who, where, when, what_log) + + -- Comment out to reduce both disk load and reliability + db_file:flush () + + if db_garbage > 5000 then db_compact () end + + if what:sub (1, #prefix) == prefix then + local command = what:sub (#prefix + 1) + local name, e = command:match ("^(%S+)%s*()") + if name == 'seen' then seen (who, where, command:sub (e)) end + end +end + +for line in io.lines () do + local msg = parse (line) + if msg.command == "PRIVMSG" then handle (msg) end +end diff --git a/plugins/xB/seen-import-xC.pl b/plugins/xB/seen-import-xC.pl new file mode 100755 index 0000000..db706a0 --- /dev/null +++ b/plugins/xB/seen-import-xC.pl @@ -0,0 +1,39 @@ +#!/usr/bin/env perl +# Creates a database for the "seen" plugin from logs for xC. +# The results may not be completely accurate but are good for jumpstarting. +# Usage: ./seen-import-xC.pl LOG-FILE... > seen.db + +use strict; +use warnings; +use File::Basename; +use Time::Piece; + +my $db = {}; +for (@ARGV) { + my $where = (basename($_) =~ /\.(.*).log/)[0]; + unless ($where) { + print STDERR "Invalid filename: $_\n"; + next; + } + + open my $fh, '<', $_ or die "Failed to open log file: $!"; + while (<$fh>) { + my ($when, $who, $who_action, $what) = + /^(.{19}) (?:<[~&@%+]*(.*?)>| \* (\S+)) (.*)/; + next unless $when; + + if ($who_action) { + $who = $who_action; + $what = "* $what"; + } + $db->{$who}->{$where} = + [Time::Piece->strptime($when, "%Y-%m-%d %T")->epoch, $what]; + } +} + +while (my ($who, $places) = each %$db) { + while (my ($where, $data) = each %$places) { + my ($when, $what) = @$data; + print ":$who PRIVMSG $where $when :$what\n"; + } +} diff --git a/plugins/xB/youtube b/plugins/xB/youtube new file mode 100755 index 0000000..feaab96 --- /dev/null +++ b/plugins/xB/youtube @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# +# xB YouTube plugin, displaying info about YouTube links +# +# Copyright 2014 - 2015, Přemysl Eric Janouch <p@janouch.name> +# See the file LICENSE for licensing information. +# + +import sys +import io +import re +import json +import urllib.request + +class Plugin: + re_msg = re.compile ('(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?' + '([^ ]+)(?: +(.*))?\r\n$') + re_args = re.compile (':?((?<=:).*|[^ ]+) *') + + def parse (self, line): + m = self.re_msg.match (line) + if m is None: + return None + + (nick, user, host, command, args) = m.groups () + args = [] if args is None else self.re_args.findall (args) + return (nick, user, host, command, args) + + def get_config (self, key): + print ("XB get_config :%s" % key) + (_, _, _, _, args) = self.parse (sys.stdin.readline ()) + return args[0] + + def bot_print (self, what): + print ('XB print :%s' % what) + +class YouTube (Plugin): + re_videos = [re.compile (x) for x in [ + r'youtube\.[a-z]+/[^ ]*[&?]v=([-\w]+)', + r'youtube\.[a-z]+/v/([-\w]+)', + r'youtu\.be/([-\w]+)' + ]] + re_playlists = [re.compile (x) for x in [ + r'youtube\.[a-z]+/playlist[&?][^ ]*(?<=&|\?)list=([-\w]+)', + ]] + + def print_info (self, channel, url, cb): + try: + data = json.loads (urllib.request.urlopen + (url, None, 30).read ().decode ('utf-8')) + + for line in map (lambda x: "YouTube: " + cb (x), data['items']): + print ("PRIVMSG %s :%s" % (channel, + line.encode ('utf-8').decode ('iso8859-1'))) + + except Exception as err: + self.bot_print ('youtube: %s' % (err)) + + def print_video_info (self, channel, video_id): + url = 'https://www.googleapis.com/youtube/v3/' \ + + 'videos?id=%s&key=%s&part=snippet,contentDetails,statistics' \ + % (video_id, self.youtube_api_key) + self.print_info (channel, url, lambda x: "%s | %s | %sx" % ( + x['snippet']['title'], + x['contentDetails']['duration'][2:].lower (), + x['statistics']['viewCount'])) + + def print_playlist_info (self, channel, playlist_id): + url = 'https://www.googleapis.com/youtube/v3/' \ + + 'playlists?id=%s&key=%s&part=snippet,contentDetails' \ + % (playlist_id, self.youtube_api_key) + self.print_info (channel, url, lambda x: "%s | %d videos" % ( + x['snippet']['title'], + x['contentDetails']['itemCount'])) + + def process_line (self, line): + msg = self.parse (line) + if msg is None: + return + + (nick, user, host, command, args) = msg + if command != 'PRIVMSG' or len (args) < 2: + return + + ctx = args[0] + if not ctx.startswith (('#', '+', '&', '!')): + ctx = nick + + for regex in self.re_videos: + for i in regex.findall (args[1]): + self.print_video_info (ctx, i) + for regex in self.re_playlists: + for i in regex.findall (args[1]): + self.print_playlist_info (ctx, i) + + def run (self): + self.youtube_api_key = self.get_config ('youtube_api_key') + if self.youtube_api_key == "": + self.bot_print ("youtube: missing `youtube_api_key'") + + print ("XB register") + + for line in sys.stdin: + self.process_line (line) + +sys.stdin = io.TextIOWrapper (sys.__stdin__.buffer, + encoding = 'iso8859-1', newline = '\r\n', line_buffering = True) +sys.stdout = io.TextIOWrapper (sys.__stdout__.buffer, + encoding = 'iso8859-1', newline = '\r\n', line_buffering = True) + +YouTube ().run () diff --git a/plugins/xC/auto-rejoin.lua b/plugins/xC/auto-rejoin.lua new file mode 100644 index 0000000..f42fb2e --- /dev/null +++ b/plugins/xC/auto-rejoin.lua @@ -0,0 +1,48 @@ +-- +-- auto-rejoin.lua: join back automatically when someone kicks you +-- +-- Copyright (c) 2016, Přemysl Eric Janouch <p@janouch.name> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +local timeout +xC.setup_config { + timeout = { + type = "integer", + comment = "auto rejoin timeout", + default = "0", + + on_change = function (v) + timeout = v + end, + validate = function (v) + if v < 0 then error ("timeout must not be negative", 0) end + end, + }, +} + +async, await = xC.async, coroutine.yield +xC.hook_irc (function (hook, server, line) + local msg = xC.parse (line) + if msg.command ~= "KICK" then return line end + + local who = msg.prefix:match ("^[^!]*") + local channel, whom = table.unpack (msg.params) + if who ~= whom and whom == server.user.nickname then + async.go (function () + await (async.timer_ms (timeout * 1000)) + server:send ("JOIN " .. channel) + end) + end + return line +end) diff --git a/plugins/xC/censor.lua b/plugins/xC/censor.lua new file mode 100644 index 0000000..49cab5b --- /dev/null +++ b/plugins/xC/censor.lua @@ -0,0 +1,90 @@ +-- +-- censor.lua: black out certain users' messages +-- +-- Copyright (c) 2016 - 2021, Přemysl Eric Janouch <p@janouch.name> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +local to_pattern = function (mask) + if not mask:match ("!") then mask = mask .. "!*" end + if not mask:match ("@") then mask = mask .. "@*" end + + -- That is, * acts like a wildcard, otherwise everything is escaped + return "^" .. mask:gsub ("[%^%$%(%)%%%.%[%]%+%-%?]", "%%%0") + :gsub ("%*", ".*") .. "$" +end + +local patterns = {} +local read_masks = function (v) + patterns = {} + local add = function (who, where) + local channels = patterns[who] or {} + table.insert (channels, where) + patterns[who] = channels + end + for item in v:lower ():gmatch ("[^,]+") do + local who, where = item:match ("^([^/]+)/*(.*)") + if who then add (to_pattern (who), where == "" or where) end + end +end + +local quote +xC.setup_config { + masks = { + type = "string_array", + default = "\"\"", + comment = "user masks (optionally \"/#channel\") to censor", + on_change = read_masks + }, + quote = { + type = "string", + default = "\"\\x0301,01\"", + comment = "formatting prefix for censored messages", + on_change = function (v) quote = v end + }, +} + +local decolor = function (text) + local rebuilt, last = {""}, 1 + for start in text:gmatch ('()\x03') do + table.insert (rebuilt, text:sub (last, start - 1)) + local sub = text:sub (start + 1) + last = start + (sub:match ('^%d%d?,%d%d?()') or sub:match ('^%d?%d?()')) + end + return table.concat (rebuilt) .. text:sub (last) +end + +local censor = function (line) + -- Taking a shortcut to avoid lengthy message reassembly + local start, text = line:match ("^(.- PRIVMSG .- :)(.*)$") + local ctcp, rest = text:match ("^(\x01%g+ )(.*)") + text = ctcp and ctcp .. quote .. decolor (rest) or quote .. decolor (text) + return start .. text +end + +xC.hook_irc (function (hook, server, line) + local msg = xC.parse (line) + if msg.command ~= "PRIVMSG" then return line end + + local channel = msg.params[1]:lower () + for who, where in pairs (patterns) do + if msg.prefix:lower ():match (who) then + for _, x in pairs (where) do + if x == true or x == channel then + return censor (line) + end + end + end + end + return line +end) diff --git a/plugins/xC/fancy-prompt.lua b/plugins/xC/fancy-prompt.lua new file mode 100644 index 0000000..0b7000c --- /dev/null +++ b/plugins/xC/fancy-prompt.lua @@ -0,0 +1,113 @@ +-- +-- fancy-prompt.lua: the fancy multiline prompt you probably want +-- +-- Copyright (c) 2016 - 2022, Přemysl Eric Janouch <p@janouch.name> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- +-- Beware that it is a hack and only goes about 90% of the way, which is why +-- this functionality is only available as a plugin in the first place +-- (well, and also for customizability). +-- +-- The biggest problem is that the way we work with Readline is incompatible +-- with multiline prompts, and normal newlines just don't work. This is being +-- circumvented by using an overflowing single-line prompt with a specially +-- crafted character in the rightmost column that prevents the bar's background +-- from spilling all over the last line. +-- +-- There is also a problem with C-r search rendering not clearing out the +-- background but to really fix that mode, we'd have to fully reimplement it +-- since its alternative prompt very often gets overriden by accident anyway. + +xC.hook_prompt (function (hook) + local current = xC.current_buffer + local chan = current.channel + local s = current.server + + local bg_color = "255" + local current_n = 0 + local active = "" + for i, buffer in ipairs (xC.buffers) do + if buffer == current then + current_n = i + elseif buffer.new_messages_count ~= buffer.new_unimportant_count then + active = active .. "," + if buffer.highlighted then + active = active .. "!" + bg_color = "224" + end + active = active .. i + end + end + local x = current_n .. ":" .. current.name + if chan and chan.users_len ~= 0 then + local params = "" + for mode, param in pairs (chan.param_modes) do + params = params .. " +" .. mode .. " " .. param + end + local modes = chan.no_param_modes .. params:sub (3) + if modes ~= "" then + x = x .. "(+" .. modes .. ")" + end + x = x .. "{" .. chan.users_len .. "}" + end + if current.hide_unimportant then + x = x .. "<H>" + end + if active ~= "" then + x = x .. " (" .. active:sub (2) .. ")" + end + + -- Readline 7.0.003 seems to be broken and completely corrupts the prompt. + -- However 8.0.004 seems to be fine with these, as is libedit 20191231-3.1. + --x = x:gsub("[\128-\255]", "?") + + -- Align to the terminal's width and apply formatting, including the hack. + local lines, cols = xC.get_screen_size () + local trailing, width = " ", xC.measure (x) + while cols > 0 and width >= cols do + x = x:sub (1, utf8.offset (x, -1) - 1) + trailing, width = ">", xC.measure (x) + end + + x = "\x01\x1b[0;4;1;38;5;16m\x1b[48;5;" .. bg_color .. "m\x02" .. + x .. string.rep (" ", cols - width - 1) .. + "\x01\x1b[0;4;1;7;38;5;" .. bg_color .. "m\x02" .. + trailing .. "\x01\x1b[0;1m\x02" + + local user_prefix = function (chan, user) + for i, chan_user in ipairs (chan.users) do + if chan_user.user == user then return chan_user.prefixes end + end + return "" + end + if s then + x = x .. "[" + local state = s.state + if state == "disconnected" or state == "connecting" then + x = x .. "(" .. state .. ")" + elseif state ~= "registered" then + x = x .. "(unregistered)" + else + local user, modes = s.user, s.user_mode + if chan then x = x .. user_prefix (chan, user) end + x = x .. user.nickname + if modes ~= "" then x = x .. "(" .. modes .. ")" end + end + x = x .. "] " + else + -- There needs to be at least one character so that the cursor + -- doesn't get damaged by our hack in that last column + x = x .. "> " + end + return x +end) diff --git a/plugins/xC/last-fm.lua b/plugins/xC/last-fm.lua new file mode 100644 index 0000000..3bdfed2 --- /dev/null +++ b/plugins/xC/last-fm.lua @@ -0,0 +1,178 @@ +-- +-- last-fm.lua: "now playing" feature using the last.fm API +-- +-- Dependencies: lua-cjson (from luarocks e.g.) +-- +-- I call this style closure-oriented programming +-- +-- Copyright (c) 2016, Přemysl Eric Janouch <p@janouch.name> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +local cjson = require "cjson" + +-- Setup configuration to load last.fm API credentials from +local user, api_key +xC.setup_config { + user = { + type = "string", + comment = "last.fm username", + on_change = function (v) user = v end + }, + api_key = { + type = "string", + comment = "last.fm API key", + on_change = function (v) api_key = v end + }, +} + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +-- Generic error reporting +local report_error = function (buffer, error) + buffer:log ("last-fm error: " .. error) +end + +-- Process data return by the server and extract the now playing song +local process = function (buffer, data, action) + -- There's no reasonable Lua package to parse HTTP that I could find + local s, e, v, status, message = string.find (data, "(%S+) (%S+) .+\r\n") + if not s then return "server returned unexpected data" end + if status ~= "200" then return status .. " " .. message end + + local s, e = string.find (data, "\r\n\r\n") + if not s then return "server returned unexpected data" end + + local parser = cjson.new () + data = parser.decode (string.sub (data, e + 1)) + if not data.recenttracks or not data.recenttracks.track then + return "invalid response" end + + -- Need to make some sense of the XML automatically converted to JSON + local text_of = function (node) + if type (node) ~= "table" then return node end + return node["#text"] ~= "" and node["#text"] or nil + end + + local name, artist, album + for i, track in ipairs (data.recenttracks.track) do + if track["@attr"] and track["@attr"].nowplaying then + if track.name then name = text_of (track.name) end + if track.artist then artist = text_of (track.artist) end + if track.album then album = text_of (track.album) end + end + end + + if not name then + action (false) + else + local np = "\"" .. name .. "\"" + if artist then np = np .. " by " .. artist end + if album then np = np .. " from " .. album end + action (np) + end +end + +-- Set up the connection and make the request +local on_connected = function (buffer, c, host, action) + -- Buffer data in the connection object + c.data = "" + c.on_data = function (data) + c.data = c.data .. data + end + + -- And process it after we receive everything + c.on_eof = function () + error = process (buffer, c.data, action) + if error then report_error (buffer, error) end + c:close () + end + c.on_error = function (e) + report_error (buffer, e) + end + + -- Make the unencrypted HTTP request + local url = "/2.0/?method=user.getrecenttracks&user=" .. user .. + "&limit=1&api_key=" .. api_key .. "&format=json" + c:send ("GET " .. url .. " HTTP/1.1\r\n") + c:send ("User-agent: last-fm.lua\r\n") + c:send ("Host: " .. host .. "\r\n") + c:send ("Connection: close\r\n") + c:send ("\r\n") +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +-- Avoid establishing more than one connection at a time +local running + +-- Initiate a connection to last.fm servers +async, await = xC.async, coroutine.yield +local make_request = function (buffer, action) + if not user or not api_key then + report_error (buffer, "configuration is incomplete") + return + end + + if running then running:cancel () end + running = async.go (function () + local c, host, e = await (async.dial ("ws.audioscrobbler.com", 80)) + if e then + report_error (buffer, e) + else + on_connected (buffer, c, host, action) + end + running = nil + end) +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +local now_playing + +local tell_song = function (buffer) + if now_playing == nil then + buffer:log ("last-fm: I don't know what you're listening to") + elseif not now_playing then + buffer:log ("last-fm: not playing anything right now") + else + buffer:log ("last-fm: now playing: " .. now_playing) + end +end + +local send_song = function (buffer) + if not now_playing then + tell_song (buffer) + else + buffer:execute ("/me is listening to " .. now_playing) + end +end + +-- Hook input to simulate new commands +xC.hook_input (function (hook, buffer, input) + if input == "/np" then + make_request (buffer, function (np) + now_playing = np + send_song (buffer) + end) + elseif input == "/np?" then + make_request (buffer, function (np) + now_playing = np + tell_song (buffer) + end) + elseif input == "/np!" then + send_song (buffer) + else + return input + end +end) diff --git a/plugins/xC/ping-timeout.lua b/plugins/xC/ping-timeout.lua new file mode 100644 index 0000000..c455c57 --- /dev/null +++ b/plugins/xC/ping-timeout.lua @@ -0,0 +1,32 @@ +-- +-- ping-timeout.lua: ping timeout readability enhancement plugin +-- +-- Copyright (c) 2015 - 2016, Přemysl Eric Janouch <p@janouch.name> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +xC.hook_irc (function (hook, server, line) + local msg = xC.parse (line) + local start, timeout = line:match ("^(.* :Ping timeout:) (%d+) seconds$") + if msg.command ~= "QUIT" or not start then + return line + end + + local minutes = timeout // 60 + if minutes == 0 then + return line + end + + local seconds = timeout % 60 + return ("%s %d minutes, %d seconds"):format (start, minutes, seconds) +end) diff --git a/plugins/xC/prime.lua b/plugins/xC/prime.lua new file mode 100644 index 0000000..23740ee --- /dev/null +++ b/plugins/xC/prime.lua @@ -0,0 +1,68 @@ +-- +-- prime.lua: highlight prime numbers in messages +-- +-- Copyright (c) 2020, Přemysl Eric Janouch <p@janouch.name> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +local smallest, highlight = 0, "\x1f" +xC.setup_config { + smallest = { + type = "integer", + default = "0", + comment = "smallest number to scan for primality", + on_change = function (v) smallest = math.max (v, 2) end + }, + highlight = { + type = "string", + default = "\"\\x1f\"", + comment = "the attribute to use for highlights", + on_change = function (v) highlight = v end + }, +} + +-- The prime test is actually very fast, so there is no DoS concern +local do_intercolour = function (text) + return tostring (text:gsub ("%f[%w_]%d+", function (n) + if tonumber (n) < smallest then return nil end + for i = 2, n ^ (1 / 2) do if (n % i) == 0 then return nil end end + return highlight .. n .. highlight + end)) +end + +local do_interlink = function (text) + local rebuilt, last = {""}, 1 + for start in text:gmatch ('()\x03') do + table.insert (rebuilt, do_intercolour (text:sub (last, start - 1))) + local sub = text:sub (start + 1) + last = start + (sub:match ('^%d%d?,%d%d?()') or sub:match ('^%d?%d?()')) + table.insert (rebuilt, text:sub (start, last - 1)) + end + return table.concat (rebuilt) .. do_intercolour (text:sub (last)) +end + +local do_message = function (text) + local rebuilt, last = {""}, 1 + for run, link, endpos in text:gmatch ('(.-)(%f[%g]https?://%g+)()') do + last = endpos + table.insert (rebuilt, do_interlink (run) .. link) + end + return table.concat (rebuilt) .. do_interlink (text:sub (last)) +end + +-- XXX: sadly it won't typically highlight primes in our own messages, +-- unless IRCv3 echo-message is on +xC.hook_irc (function (hook, server, line) + local start, message = line:match ("^(.- PRIVMSG .- :)(.*)$") + return message and start .. do_message (message) or line +end) diff --git a/plugins/xC/slack.lua b/plugins/xC/slack.lua new file mode 100644 index 0000000..c1a08de --- /dev/null +++ b/plugins/xC/slack.lua @@ -0,0 +1,147 @@ +-- +-- slack.lua: try to fix up UX when using the Slack IRC gateway +-- +-- Copyright (c) 2017, Přemysl Eric Janouch <p@janouch.name> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +local servers = {} +local read_servers = function (v) + servers = {} + for name in v:lower ():gmatch "[^,]+" do + servers[name] = true + end +end + +-- This is a reverse list of Slack's automatic emoji, noseless forms +local unemojify, emoji, emoji_default = false, {}, { + heart = "<3", + broken_heart = "</3", + sunglasses = "8)", + anguished = "D:", + cry = ":'(", + monkey_face = ":o)", + kiss = ":*", + smiley = "=)", + smile = ":D", + wink = ";)", + laughing = ":>", + neutral_face = ":|", + open_mouth = ":o", + angry = ">:(", + slightly_smiling_face = ":)", + disappointed = ":(", + confused = ":/", + stuck_out_tongue = ":p", + stuck_out_tongue_winking_eye = ";p", +} +local load_emoji = function (extra) + emoji = {} + for k, v in pairs (emoji_default) do emoji[k] = v end + for k, v in extra:gmatch "([^,]+) ([^,]+)" do emoji[k] = v end +end + +xC.setup_config { + servers = { + type = "string_array", + default = "\"\"", + comment = "list of server names that are Slack IRC gateways", + on_change = read_servers + }, + unemojify = { + type = "boolean", + default = "true", + comment = "convert emoji to normal ASCII emoticons", + on_change = function (v) unemojify = v end + }, + extra_emoji = { + type = "string_array", + default = "\"grinning :)),joy :'),innocent o:),persevere >_<\"", + comment = "overrides or extra emoji for unemojify", + on_change = function (v) load_emoji (v) end + } +} + +-- We can handle external messages about what we've supposedly sent just fine, +-- so let's get rid of that "[username] some message sent from the web UI" crap +xC.hook_irc (function (hook, server, line) + local msg, us = xC.parse (line), server.user + if not servers[server.name] or msg.command ~= "PRIVMSG" or not us + or msg.params[1]:lower () ~= us.nickname:lower () then return line end + + -- Taking a shortcut to avoid lengthy message reassembly + local quoted_nick = us.nickname:gsub ("[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0") + local text = line:match ("^.- PRIVMSG .- :%[" .. quoted_nick .. "%] (.*)$") + if not text then return line end + return ":" .. us.nickname .. "!" .. server.irc_user_host .. " PRIVMSG " + .. msg.prefix:match "^[^!@]*" .. " :" .. text +end) + +-- Unfuck emoji and :nick!nick@irc.tinyspeck.com MODE #channel +v nick : active +xC.hook_irc (function (hook, server, line) + if not servers[server.name] then return line end + if unemojify then + local start, text = line:match ("^(.- PRIVMSG .- :)(.*)$") + if start then return start .. text:gsub (":([a-z_]+):", function (name) + if emoji[name] then return emoji[name] end + return ":" .. name .. ":" + end) end + end + return line:gsub ("^(:%S+ MODE .+) : .*", "%1") +end) + +-- The gateway simply ignores the NAMES command altogether +xC.hook_input (function (hook, buffer, input) + if not buffer.channel or not servers[buffer.server.name] + or not input:match "^/names%s*$" then return input end + + local users = buffer.channel.users + table.sort (users, function (a, b) + if a.prefixes > b.prefixes then return true end + if a.prefixes < b.prefixes then return false end + return a.user.nickname < b.user.nickname + end) + + local names = "Users on " .. buffer.channel.name .. ":" + for i, chan_user in ipairs (users) do + names = names .. " " .. chan_user.prefixes .. chan_user.user.nickname + end + buffer:log (names) +end) + +xC.hook_completion (function (hook, data, word) + local chan = xC.current_buffer.channel + local server = xC.current_buffer.server + if not chan or not servers[server.name] then return end + + -- In /commands there is typically no desire at all to add the at sign + if data.location == 1 and data.words[1]:match "^/" then return end + + -- Handle both when the at sign is already there and when it is not + local needle = word:gsub ("^@", ""):lower () + + local t = {} + local try = function (name) + if data.location == 0 then name = name .. ":" end + if name:sub (1, #needle):lower () == needle then + table.insert (t, "@" .. name) + end + end + for _, chan_user in ipairs (chan.users) do + try (chan_user.user.nickname) + end + for _, special in ipairs { "channel", "here" } do + try (special) + end + return t +end) diff --git a/plugins/xC/thin-cursor.lua b/plugins/xC/thin-cursor.lua new file mode 100644 index 0000000..d0fbf38 --- /dev/null +++ b/plugins/xC/thin-cursor.lua @@ -0,0 +1,27 @@ +-- +-- thin-cursor.lua: set a thin cursor +-- +-- Copyright (c) 2016, Přemysl Eric Janouch <p@janouch.name> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- +-- If tmux doesn't work, add the following to its configuration: +-- set -as terminal-overrides ',*:Ss=\E[%p1%d q:Se=\E[2 q' +-- Change the "2" as per http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + +local out = io.output () +out:write ("\x1b[6 q"):flush () + +-- By registering a global variable, we get notified about plugin unload +x = setmetatable ({}, { __gc = function () + out:write ("\x1b[2 q"):flush () +end }) diff --git a/plugins/xC/utm-filter.lua b/plugins/xC/utm-filter.lua new file mode 100644 index 0000000..b708c12 --- /dev/null +++ b/plugins/xC/utm-filter.lua @@ -0,0 +1,66 @@ +-- +-- utm-filter.lua: filter out Google Analytics bullshit etc. from URLs +-- +-- Copyright (c) 2015 - 2021, Přemysl Eric Janouch <p@janouch.name> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- A list of useless URL parameters that don't affect page function +local banned = { + gclid = 1, + + -- Alas, Facebook no longer uses this parameter, see: + -- https://news.ycombinator.com/item?id=32117489 + fbclid = 1, + + utm_source = 1, + utm_medium = 1, + utm_term = 1, + utm_content = 1, + utm_campaign = 1, +} + +-- Go through a parameter list and throw out any banned elements +local do_args = function (args) + local filtered = {} + for part in args:gmatch ("[^&]+") do + if not banned[part:match ("^[^=]*")] then + table.insert (filtered, part) + end + end + return table.concat (filtered, "&") +end + +-- Filter parameters in both the query and the fragment part of an URL +local do_single_url = function (url) + return url:gsub ('^([^?#]*)%?([^#]*)', function (start, query) + local clean = do_args (query) + return #clean > 0 and start .. "?" .. clean or start + end, 1):gsub ('^([^#]*)#(.*)', function (start, fragment) + local clean = do_args (fragment) + return #clean > 0 and start .. "#" .. clean or start + end, 1) +end + +local do_text = function (text) + return text:gsub ('%f[%g]https?://%g+', do_single_url) +end + +xC.hook_irc (function (hook, server, line) + local start, message = line:match ("^(.* :)(.*)$") + return message and start .. do_text (message) or line +end) + +xC.hook_input (function (hook, buffer, input) + return do_text (input) +end) @@ -0,0 +1,50 @@ +#!/usr/bin/expect -f +# Very basic end-to-end testing for CI + +# Run the daemon to test against +system ./xD --write-default-cfg +spawn ./xD -d + +# 10 seconds is a bit too much +set timeout 5 + +spawn ./xC + +# Fuck this Tcl shit, I want the exit code +expect_after { + eof { + puts "" + puts "Child exited prematurely" + exit 1 + } +} + +# Connect to the daemon +send "/server add localhost\n" +expect "]" +send "/set servers.localhost.addresses = \"localhost\"\n" +expect "Option changed" +send "/disconnect\n" +expect "]" +send "/connect\n" +expect "Connection established" + +# Try some chatting +send "/join #test\n" +expect "has joined" +send "Hello\n" +expect "Hello" + +# Attributes +send "\x1bmbBold text! \x1bmc0,5And colors.\n" +expect "]" + +# Try basic commands +send "/set\n" +expect "]" +send "/help\n" +expect "]" + +# Quit +send "/quit\n" +expect "Shutting down" diff --git a/test-nick-colors b/test-nick-colors new file mode 100755 index 0000000..b6c97cc --- /dev/null +++ b/test-nick-colors @@ -0,0 +1,26 @@ +#!/bin/sh +# Check whether the terminal colours filtered by our algorithm are legible +export example=$( + tcc "-run -lm" - <<-END + #include <stddef.h> + #include <stdio.h> + #include <math.h> + + #define N_ELEMENTS(a) (sizeof (a) / sizeof ((a)[0])) + + $(perl -0777 -ne 'print $& if /^.*?\nfilter_color(?s:.*?)^}$/m' \ + "$(dirname "$0")"/xC.c) + + void main () { + size_t len = 0; + int *table = filter_color_cube_for_acceptable_nick_colors (&len); + for (size_t i = 0; i < len; i++) + printf ("<@\\x1b[38;5;%dmIRCuser\\x1b[m> I'm typing!\n", table[i]); + } + END +) + +# Both should give acceptable results, +# which results in a bad compromise that the main author himself needs +xterm -bg black -fg white -e 'echo $example; cat' & +xterm -bg white -fg black -e 'echo $example; cat' & diff --git a/test-static b/test-static new file mode 100755 index 0000000..85d7f4f --- /dev/null +++ b/test-static @@ -0,0 +1,14 @@ +#!/bin/sh +# We don't use printf's percent notation with our custom logging mechanism, +# so the compiler cannot check it for us like it usually does +perl -n0777 - "$(dirname "$0")"/xC.c <<-'END' + while (/\blog_[^ ]+\s*\([^"()]*"[^"]*%[^%][^"]*"/gm) { + my ($p, $m) = ($`, $&); + printf "$ARGV:%d: suspicious log format string: %s...\n", + (1 + $p =~ tr/\n//), ($m =~ s/\s+/ /rg); + $status = 1; + } + END { + exit $status; + } +END @@ -0,0 +1,104 @@ +xB(1) +===== +:doctype: manpage +:manmanual: xK Manual +:mansource: xK {release-version} + +Name +---- +xB - modular IRC bot + +Synopsis +-------- +*xB* [_OPTION_]... + +Description +----------- +*xB* is a modular IRC bot with a programming language-agnostic plugin +architecture based on co-processes. + +Options +------- +*-d*, *--debug*:: + Print more information to help debug various issues. + +*-h*, *--help*:: + Display a help message and exit. + +*-V*, *--version*:: + Output version information and exit. + +*--write-default-cfg*[**=**__PATH__]:: + Write a configuration file with defaults, show its path and exit. ++ +The file will be appropriately commented. + +Commands +-------- +The bot accepts the following commands when they either appear quoted by the +*prefix* string on a channel or unquoted as a private message sent directly +to the bot, on the condition that the sending user matches the *admin* +regular expression or that it is left unset: + +*quote* [_message_]:: + Forwards the message to the IRC server as-is. +*quit* [_reason_]:: + Quits the IRC server, with an optional reason string. +*status*:: + Sends back a report about its state and all loaded plugins. +*load* _plugin_[, _plugin_]...:: + Tries to load the given plugins. +*unload* _plugin_[, _plugin_]...:: + Tries to unload the given plugins. +*reload* _plugin_[, _plugin_]...:: + The same as *unload* immediately followed by *load*. + +Plugins +------- +Plugins communicate with the bot over their standard input and output streams +using the IRC protocol. (Caveat: the standard C library doesn't automatically +flush FILE streams for pipes on newlines.) A special *XB* command is introduced +for RPC, with the following subcommands: + +*XB get_config* __key__:: + Request the value of the given configuration option. If no such option + exists, the value will be empty. The response will be delivered in + the following format: ++ +.... +XB :value +.... ++ +This is particularly useful for retrieving the *prefix* string. + +*XB print* _message_:: + Make the bot print the _message_ on its standard output. + +*XB register*:: + Once a plugin issues this command, it will start receiving all of the bot's + incoming IRC traffic, which includes data from the initialization period. + +All other commands will be forwarded directly to the IRC server. + +Files +----- +*xB* follows the XDG Base Directory Specification. + +_~/.config/xB/xB.conf_:: + The bot's configuration file. Use the *--write-default-cfg* option + to create a new one for editing. + +_~/.local/share/xB/_:: + The initial working directory for plugins, in which they may create private + databases or other files as needed. + +_~/.local/share/xB/plugins/_:: +_/usr/local/share/xB/plugins/_:: +_/usr/share/xB/plugins/_:: + Plugins are searched for in these directories, in order, unless + the *plugin_dir* configuration option overrides this. + +Reporting bugs +-------------- +Use https://git.janouch.name/p/xK to report bugs, request features, +or submit pull requests. @@ -0,0 +1,2064 @@ +/* + * xB.c: a modular IRC bot + * + * Copyright (c) 2014 - 2020, Přemysl Eric Janouch <p@janouch.name> + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#include "config.h" +#define PROGRAM_NAME "xB" + +#include "common.c" + +// --- Configuration (application-specific) ------------------------------------ + +static struct simple_config_item g_config_table[] = +{ + { "nickname", "xB", "IRC nickname" }, + { "username", "bot", "IRC user name" }, + { "realname", "xB IRC bot", "IRC real name/e-mail" }, + + { "irc_host", NULL, "Address of the IRC server" }, + { "irc_port", "6667", "Port of the IRC server" }, + { "tls", "off", "Whether to use TLS" }, + { "tls_cert", NULL, "Client TLS certificate (PEM)" }, + { "tls_verify", "on", "Whether to verify certificates" }, + { "tls_ca_file", NULL, "OpenSSL CA bundle file" }, + { "tls_ca_path", NULL, "OpenSSL CA bundle path" }, + { "autojoin", NULL, "Channels to join on start" }, + { "reconnect", "on", "Whether to reconnect on error" }, + { "reconnect_delay", "5", "Time between reconnecting" }, + + { "socks_host", NULL, "Address of a SOCKS 4a/5 proxy" }, + { "socks_port", "1080", "SOCKS port number" }, + { "socks_username", NULL, "SOCKS auth. username" }, + { "socks_password", NULL, "SOCKS auth. password" }, + + { "prefix", ":", "The prefix for bot commands" }, + { "admin", NULL, "Host mask for administrators" }, + { "plugins", NULL, "The plugins to load on startup" }, + { "plugin_dir", NULL, "Plugin search path override" }, + { "recover", "on", "Whether to re-launch on crash" }, + + { NULL, NULL, NULL } +}; + +// --- Application data -------------------------------------------------------- + +struct plugin +{ + LIST_HEADER (struct plugin) + struct bot_context *ctx; ///< Parent context + + char *name; ///< Plugin identifier + pid_t pid; ///< PID of the plugin process + + bool is_zombie; ///< Whether the child is a zombie + bool initialized; ///< Ready to exchange IRC messages + struct str queued_output; ///< Output queued up until initialized + + // Since we're doing non-blocking I/O, we need to queue up data so that + // we don't stall on plugins unnecessarily. + + int read_fd; ///< The read end of the comm. pipe + int write_fd; ///< The write end of the comm. pipe + + struct poller_fd read_event; ///< Read FD event + struct poller_fd write_event; ///< Write FD event + + struct str read_buffer; ///< Unprocessed input + struct str write_buffer; ///< Output yet to be sent out +}; + +static struct plugin * +plugin_new (void) +{ + struct plugin *self = xcalloc (1, sizeof *self); + self->pid = -1; + self->queued_output = str_make (); + + self->read_fd = -1; + self->read_buffer = str_make (); + self->write_fd = -1; + self->write_buffer = str_make (); + return self; +} + +static void +plugin_destroy (struct plugin *self) +{ + soft_assert (self->pid == -1); + free (self->name); + + str_free (&self->read_buffer); + if (!soft_assert (self->read_fd == -1)) + xclose (self->read_fd); + + str_free (&self->write_buffer); + if (!soft_assert (self->write_fd == -1)) + xclose (self->write_fd); + + if (!self->initialized) + str_free (&self->queued_output); + + free (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct bot_context +{ + struct str_map config; ///< User configuration + regex_t *admin_re; ///< Regex to match our administrator + bool reconnect; ///< Whether to reconnect on conn. fail. + unsigned long reconnect_delay; ///< Reconnect delay in seconds + + int irc_fd; ///< Socket FD of the server + struct str read_buffer; ///< Input yet to be processed + struct poller_fd irc_event; ///< IRC FD event + bool irc_registered; ///< Whether we may send messages now + + struct poller_fd signal_event; ///< Signal FD event + struct poller_timer ping_tmr; ///< We should send a ping + struct poller_timer timeout_tmr; ///< Connection seems to be dead + struct poller_timer reconnect_tmr; ///< We should reconnect now + + SSL_CTX *ssl_ctx; ///< SSL context + SSL *ssl; ///< SSL connection + + struct plugin *plugins; ///< Linked list of plugins + struct str_map plugins_by_name; ///< Indexes @em plugins by their name + + struct poller poller; ///< Manages polled descriptors + bool quitting; ///< User requested quitting + bool polling; ///< The event loop is running +}; + +static void on_irc_ping_timeout (void *user_data); +static void on_irc_timeout (void *user_data); +static void on_irc_reconnect_timeout (void *user_data); + +static void +bot_context_init (struct bot_context *self) +{ + self->config = str_map_make (free); + simple_config_load_defaults (&self->config, g_config_table); + self->admin_re = NULL; + + self->irc_fd = -1; + self->read_buffer = str_make (); + self->irc_registered = false; + + self->ssl = NULL; + self->ssl_ctx = NULL; + + self->plugins = NULL; + self->plugins_by_name = str_map_make (NULL); + + poller_init (&self->poller); + self->quitting = false; + self->polling = false; + + self->timeout_tmr = poller_timer_make (&self->poller); + self->timeout_tmr.dispatcher = on_irc_timeout; + self->timeout_tmr.user_data = self; + + self->ping_tmr = poller_timer_make (&self->poller); + self->ping_tmr.dispatcher = on_irc_ping_timeout; + self->ping_tmr.user_data = self; + + self->reconnect_tmr = poller_timer_make (&self->poller); + self->reconnect_tmr.dispatcher = on_irc_reconnect_timeout; + self->reconnect_tmr.user_data = self; +} + +static void +bot_context_free (struct bot_context *self) +{ + str_map_free (&self->config); + if (self->admin_re) + regex_free (self->admin_re); + str_free (&self->read_buffer); + + // TODO: terminate the plugins properly before this is called + LIST_FOR_EACH (struct plugin, link, self->plugins) + plugin_destroy (link); + + if (self->irc_fd != -1) + { + poller_fd_reset (&self->irc_event); + xclose (self->irc_fd); + } + if (self->ssl) + SSL_free (self->ssl); + if (self->ssl_ctx) + SSL_CTX_free (self->ssl_ctx); + + str_map_free (&self->plugins_by_name); + poller_free (&self->poller); +} + +static void +irc_shutdown (struct bot_context *ctx) +{ + // TODO: set a timer after which we cut the connection? + // Generally non-critical + if (ctx->ssl) + soft_assert (SSL_shutdown (ctx->ssl) != -1); + else + soft_assert (shutdown (ctx->irc_fd, SHUT_WR) == 0); +} + +static void +try_finish_quit (struct bot_context *ctx) +{ + if (ctx->quitting && ctx->irc_fd == -1 && !ctx->plugins) + ctx->polling = false; +} + +static bool plugin_zombify (struct plugin *); + +static void +initiate_quit (struct bot_context *ctx) +{ + // Initiate bringing down of the two things that block our shutdown: + // a/ the IRC socket, b/ our child processes: + + for (struct plugin *plugin = ctx->plugins; + plugin; plugin = plugin->next) + plugin_zombify (plugin); + if (ctx->irc_fd != -1) + irc_shutdown (ctx); + + ctx->quitting = true; + try_finish_quit (ctx); +} + +static bool irc_send (struct bot_context *ctx, + const char *format, ...) ATTRIBUTE_PRINTF (2, 3); + +static bool +irc_send (struct bot_context *ctx, const char *format, ...) +{ + va_list ap; + + if (g_debug_mode) + { + fputs ("[IRC] <== \"", stderr); + va_start (ap, format); + vfprintf (stderr, format, ap); + va_end (ap); + fputs ("\"\n", stderr); + } + + if (!soft_assert (ctx->irc_fd != -1)) + return false; + + va_start (ap, format); + struct str str = str_make (); + str_append_vprintf (&str, format, ap); + str_append (&str, "\r\n"); + va_end (ap); + + bool result = true; + if (ctx->ssl) + { + // TODO: call SSL_get_error() to detect if a clean shutdown has occured + ERR_clear_error (); + if (SSL_write (ctx->ssl, str.str, str.len) != (int) str.len) + { + print_debug ("%s: %s: %s", __func__, "SSL_write", + xerr_describe_error ()); + result = false; + } + } + else if (write (ctx->irc_fd, str.str, str.len) != (ssize_t) str.len) + { + print_debug ("%s: %s: %s", __func__, "write", strerror (errno)); + result = false; + } + + str_free (&str); + return result; +} + +static bool +irc_get_boolean_from_config + (struct bot_context *ctx, const char *name, bool *value, struct error **e) +{ + const char *str = str_map_find (&ctx->config, name); + hard_assert (str != NULL); + + if (set_boolean_if_valid (value, str)) + return true; + + return error_set (e, "invalid configuration value for `%s'", name); +} + +static bool +irc_initialize_ca_set (SSL_CTX *ssl_ctx, const char *file, const char *path, + struct error **e) +{ + ERR_clear_error (); + + if (file || path) + { + if (SSL_CTX_load_verify_locations (ssl_ctx, file, path)) + return true; + + return error_set (e, "%s: %s", + "failed to set locations for the CA certificate bundle", + xerr_describe_error ()); + } + + if (!SSL_CTX_set_default_verify_paths (ssl_ctx)) + return error_set (e, "%s: %s", + "couldn't load the default CA certificate bundle", + xerr_describe_error ()); + return true; +} + +static bool +irc_initialize_ca (struct bot_context *ctx, struct error **e) +{ + const char *ca_file = str_map_find (&ctx->config, "tls_ca_file"); + const char *ca_path = str_map_find (&ctx->config, "tls_ca_path"); + + char *full_file = ca_file + ? resolve_filename (ca_file, resolve_relative_config_filename) : NULL; + char *full_path = ca_path + ? resolve_filename (ca_path, resolve_relative_config_filename) : NULL; + + bool ok = false; + if (ca_file && !full_file) + error_set (e, "couldn't find the CA bundle file"); + else if (ca_path && !full_path) + error_set (e, "couldn't find the CA bundle path"); + else + ok = irc_initialize_ca_set (ctx->ssl_ctx, full_file, full_path, e); + + free (full_file); + free (full_path); + return ok; +} + +static bool +irc_initialize_ssl_ctx (struct bot_context *ctx, struct error **e) +{ + // Disable deprecated protocols (see RFC 7568) + SSL_CTX_set_options (ctx->ssl_ctx, SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3); + + bool verify; + if (!irc_get_boolean_from_config (ctx, "tls_verify", &verify, e)) + return false; + SSL_CTX_set_verify (ctx->ssl_ctx, + verify ? SSL_VERIFY_PEER : SSL_VERIFY_NONE, NULL); + + struct error *error = NULL; + if (!irc_initialize_ca (ctx, &error)) + { + if (verify) + { + error_propagate (e, error); + return false; + } + + // Only inform the user if we're not actually verifying + print_warning ("%s", error->message); + error_free (error); + } + return true; +} + +static bool +irc_initialize_tls (struct bot_context *ctx, struct error **e) +{ + const char *error_info = NULL; + ctx->ssl_ctx = SSL_CTX_new (SSLv23_client_method ()); + if (!ctx->ssl_ctx) + goto error_ssl_1; + if (!irc_initialize_ssl_ctx (ctx, e)) + goto error_ssl_2; + + ctx->ssl = SSL_new (ctx->ssl_ctx); + if (!ctx->ssl) + goto error_ssl_2; + + const char *tls_cert = str_map_find (&ctx->config, "tls_cert"); + if (tls_cert) + { + char *path = resolve_filename + (tls_cert, resolve_relative_config_filename); + if (!path) + print_error ("%s: %s", "cannot open file", tls_cert); + // XXX: perhaps we should read the file ourselves for better messages + else if (!SSL_use_certificate_file (ctx->ssl, path, SSL_FILETYPE_PEM) + || !SSL_use_PrivateKey_file (ctx->ssl, path, SSL_FILETYPE_PEM)) + print_error ("%s: %s", "setting the TLS client certificate failed", + xerr_describe_error ()); + free (path); + } + + SSL_set_connect_state (ctx->ssl); + if (!SSL_set_fd (ctx->ssl, ctx->irc_fd)) + goto error_ssl_3; + // Avoid SSL_write() returning SSL_ERROR_WANT_READ + SSL_set_mode (ctx->ssl, SSL_MODE_AUTO_RETRY); + + switch (xssl_get_error (ctx->ssl, SSL_connect (ctx->ssl), &error_info)) + { + case SSL_ERROR_NONE: + return true; + case SSL_ERROR_ZERO_RETURN: + error_info = "server closed the connection"; + default: + break; + } + +error_ssl_3: + SSL_free (ctx->ssl); + ctx->ssl = NULL; +error_ssl_2: + SSL_CTX_free (ctx->ssl_ctx); + ctx->ssl_ctx = NULL; +error_ssl_1: + if (!error_info) + error_info = xerr_describe_error (); + return error_set (e, "%s: %s", "could not initialize TLS", error_info); +} + +static bool +irc_establish_connection (struct bot_context *ctx, + const char *host, const char *port, struct error **e) +{ + struct addrinfo gai_hints, *gai_result, *gai_iter; + memset (&gai_hints, 0, sizeof gai_hints); + gai_hints.ai_socktype = SOCK_STREAM; + + int err = getaddrinfo (host, port, &gai_hints, &gai_result); + if (err) + return error_set (e, "%s: %s: %s", "connection failed", + "getaddrinfo", gai_strerror (err)); + + int sockfd; + for (gai_iter = gai_result; gai_iter; gai_iter = gai_iter->ai_next) + { + sockfd = socket (gai_iter->ai_family, + gai_iter->ai_socktype, gai_iter->ai_protocol); + if (sockfd == -1) + continue; + set_cloexec (sockfd); + + int yes = 1; + soft_assert (setsockopt (sockfd, SOL_SOCKET, SO_KEEPALIVE, + &yes, sizeof yes) != -1); + + const char *real_host = host; + + // Let's try to resolve the address back into a real hostname; + // we don't really need this, so we can let it quietly fail + char buf[NI_MAXHOST]; + err = getnameinfo (gai_iter->ai_addr, gai_iter->ai_addrlen, + buf, sizeof buf, NULL, 0, 0); + if (err) + print_debug ("%s: %s", "getnameinfo", gai_strerror (err)); + else + real_host = buf; + + // XXX: we shouldn't mix these statuses with `struct error'; choose 1! + char *address = format_host_port_pair (real_host, port); + print_status ("connecting to %s...", address); + free (address); + + if (!connect (sockfd, gai_iter->ai_addr, gai_iter->ai_addrlen)) + break; + + xclose (sockfd); + } + + freeaddrinfo (gai_result); + + if (!gai_iter) + return error_set (e, "connection failed"); + + ctx->irc_fd = sockfd; + return true; +} + +// --- Signals ----------------------------------------------------------------- + +static int g_signal_pipe[2]; ///< A pipe used to signal... signals + +static struct strv + g_original_argv, ///< Original program arguments + g_recovery_env; ///< Environment for re-exec recovery + +/// Program termination has been requested by a signal +static volatile sig_atomic_t g_termination_requested; + +/// Points to startup reason location within `g_recovery_environment' +static char **g_startup_reason_location; +/// The environment variable used to pass the startup reason when re-executing +static const char g_startup_reason_str[] = "STARTUP_REASON"; + +static void +sigchld_handler (int signum) +{ + (void) signum; + + int original_errno = errno; + // Just so that the read end of the pipe wakes up the poller. + // NOTE: Linux has signalfd() and eventfd(), and the BSD's have kqueue. + // All of them are better than this approach, although platform-specific. + if (write (g_signal_pipe[1], "c", 1) == -1) + soft_assert (errno == EAGAIN); + errno = original_errno; +} + +static void +sigterm_handler (int signum) +{ + (void) signum; + + g_termination_requested = true; + + int original_errno = errno; + if (write (g_signal_pipe[1], "t", 1) == -1) + soft_assert (errno == EAGAIN); + errno = original_errno; +} + +static void +setup_signal_handlers (void) +{ + if (pipe (g_signal_pipe) == -1) + exit_fatal ("%s: %s", "pipe", strerror (errno)); + + set_cloexec (g_signal_pipe[0]); + set_cloexec (g_signal_pipe[1]); + + // So that the pipe cannot overflow; it would make write() block within + // the signal handler, which is something we really don't want to happen. + // The same holds true for read(). + set_blocking (g_signal_pipe[0], false); + set_blocking (g_signal_pipe[1], false); + + struct sigaction sa; + sa.sa_flags = SA_RESTART; + sa.sa_handler = sigchld_handler; + sigemptyset (&sa.sa_mask); + + if (sigaction (SIGCHLD, &sa, NULL) == -1) + exit_fatal ("sigaction: %s", strerror (errno)); + + signal (SIGPIPE, SIG_IGN); + + sa.sa_handler = sigterm_handler; + if (sigaction (SIGINT, &sa, NULL) == -1 + || sigaction (SIGTERM, &sa, NULL) == -1) + exit_fatal ("sigaction: %s", strerror (errno)); +} + +static void +translate_signal_info (int no, const char **name, int code, const char **reason) +{ + if (code == SI_USER) *reason = "signal sent by kill()"; + if (code == SI_QUEUE) *reason = "signal sent by sigqueue()"; + + switch (no) + { + case SIGILL: + *name = "SIGILL"; + if (code == ILL_ILLOPC) *reason = "illegal opcode"; + if (code == ILL_ILLOPN) *reason = "illegal operand"; + if (code == ILL_ILLADR) *reason = "illegal addressing mode"; + if (code == ILL_ILLTRP) *reason = "illegal trap"; + if (code == ILL_PRVOPC) *reason = "privileged opcode"; + if (code == ILL_PRVREG) *reason = "privileged register"; + if (code == ILL_COPROC) *reason = "coprocessor error"; + if (code == ILL_BADSTK) *reason = "internal stack error"; + break; + case SIGFPE: + *name = "SIGFPE"; + if (code == FPE_INTDIV) *reason = "integer divide by zero"; + if (code == FPE_INTOVF) *reason = "integer overflow"; + if (code == FPE_FLTDIV) *reason = "floating-point divide by zero"; + if (code == FPE_FLTOVF) *reason = "floating-point overflow"; + if (code == FPE_FLTUND) *reason = "floating-point underflow"; + if (code == FPE_FLTRES) *reason = "floating-point inexact result"; + if (code == FPE_FLTINV) *reason = "invalid floating-point operation"; + if (code == FPE_FLTSUB) *reason = "subscript out of range"; + break; + case SIGSEGV: + *name = "SIGSEGV"; + if (code == SEGV_MAPERR) + *reason = "address not mapped to object"; + if (code == SEGV_ACCERR) + *reason = "invalid permissions for mapped object"; + break; + case SIGBUS: + *name = "SIGBUS"; + if (code == BUS_ADRALN) *reason = "invalid address alignment"; + if (code == BUS_ADRERR) *reason = "nonexistent physical address"; + if (code == BUS_OBJERR) *reason = "object-specific hardware error"; + break; + default: + *name = NULL; + } +} + +static void +recovery_handler (int signum, siginfo_t *info, void *context) +{ + (void) context; + + // TODO: maybe try to force a core dump like this: if (fork() == 0) return; + // TODO: maybe we could even send "\r\nQUIT :reason\r\n" to the server. >_> + // As long as we're not connected via TLS, that is. + + const char *signal_name = NULL, *reason = NULL; + translate_signal_info (signum, &signal_name, info->si_code, &reason); + + char buf[128], numbuf[8]; + if (!signal_name) + { + snprintf (numbuf, sizeof numbuf, "%d", signum); + signal_name = numbuf; + } + + if (reason) + snprintf (buf, sizeof buf, "%s=%s: %s: %s", g_startup_reason_str, + "signal received", signal_name, reason); + else + snprintf (buf, sizeof buf, "%s=%s: %s", g_startup_reason_str, + "signal received", signal_name); + *g_startup_reason_location = buf; + + // Avoid annoying resource intensive infinite loops by sleeping for a bit + (void) sleep (1); + + // TODO: maybe pregenerate the path, see the following for some other ways + // that would be illegal to do from within a signal handler: + // http://stackoverflow.com/a/1024937 + // http://stackoverflow.com/q/799679 + // Especially if we change the current working directory in the program. + // + // Note that I can just overwrite g_orig_argv[0]. + + // NOTE: our children will read EOF on the read ends of their pipes as a + // a result of O_CLOEXEC. That should be enough to make them terminate. + + char **argv = g_original_argv.vector, **argp = g_recovery_env.vector; + execve ("/proc/self/exe", argv, argp); // Linux + execve ("/proc/curproc/file", argv, argp); // BSD + execve ("/proc/curproc/exe", argv, argp); // BSD + execve ("/proc/self/path/a.out", argv, argp); // Solaris + execve (argv[0], argv, argp); // unreliable fallback + + // Let's just crash + perror ("execve"); + signal (signum, SIG_DFL); + raise (signum); +} + +static void +prepare_recovery_environment (void) +{ + g_recovery_env = strv_make (); + strv_append_vector (&g_recovery_env, environ); + + // Prepare a location within the environment where we will put the startup + // (or maybe rather restart) reason in case of an irrecoverable error. + char **iter; + for (iter = g_recovery_env.vector; *iter; iter++) + { + const size_t len = sizeof g_startup_reason_str - 1; + if (!strncmp (*iter, g_startup_reason_str, len) && (*iter)[len] == '=') + break; + } + + if (*iter) + g_startup_reason_location = iter; + else + { + g_startup_reason_location = g_recovery_env.vector + g_recovery_env.len; + strv_append (&g_recovery_env, ""); + } +} + +static bool +setup_recovery_handler (struct bot_context *ctx, struct error **e) +{ + bool recover; + if (!irc_get_boolean_from_config (ctx, "recover", &recover, e)) + return false; + if (!recover) + return true; + + // Make sure these signals aren't blocked, otherwise we would be unable + // to handle them, making the critical conditions fatal. + sigset_t mask; + sigemptyset (&mask); + sigaddset (&mask, SIGSEGV); + sigaddset (&mask, SIGBUS); + sigaddset (&mask, SIGFPE); + sigaddset (&mask, SIGILL); + sigprocmask (SIG_UNBLOCK, &mask, NULL); + + struct sigaction sa; + sa.sa_flags = SA_SIGINFO; + sa.sa_sigaction = recovery_handler; + sigemptyset (&sa.sa_mask); + + prepare_recovery_environment (); + + // TODO: also handle SIGABRT... or avoid doing abort() in the first place? + if (sigaction (SIGSEGV, &sa, NULL) == -1 + || sigaction (SIGBUS, &sa, NULL) == -1 + || sigaction (SIGFPE, &sa, NULL) == -1 + || sigaction (SIGILL, &sa, NULL) == -1) + print_error ("sigaction: %s", strerror (errno)); + return true; +} + +// --- Plugins ----------------------------------------------------------------- + +/// The name of the special IRC command for interprocess communication +static const char *plugin_ipc_command = "XB"; + +static struct plugin * +plugin_find_by_pid (struct bot_context *ctx, pid_t pid) +{ + struct plugin *iter; + for (iter = ctx->plugins; iter; iter = iter->next) + if (iter->pid == pid) + return iter; + return NULL; +} + +static bool +plugin_zombify (struct plugin *plugin) +{ + if (plugin->is_zombie) + return false; + + // FIXME: make sure that we don't remove entries from the poller while we + // still may have stuff to read; maybe just check that the read pipe is + // empty before closing it... and then on EOF check if `pid == -1' and + // only then dispose of it (it'd be best to simulate that both of these + // cases may happen). + poller_fd_reset (&plugin->write_event); + + // TODO: try to flush the write buffer (non-blocking)? + + // The plugin should terminate itself after it receives EOF. + xclose (plugin->write_fd); + plugin->write_fd = -1; + + // Make it a pseudo-anonymous zombie. In this state we process any + // remaining commands it attempts to send to us before it finally dies. + str_map_set (&plugin->ctx->plugins_by_name, plugin->name, NULL); + plugin->is_zombie = true; + + // TODO: wait a few seconds and then send SIGKILL to the plugin + return true; +} + +static void +on_plugin_writable (const struct pollfd *fd, struct plugin *plugin) +{ + struct str *buf = &plugin->write_buffer; + size_t written_total = 0; + + if (fd->revents & ~(POLLOUT | POLLHUP | POLLERR)) + print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents); + + while (written_total != buf->len) + { + ssize_t n_written = write (fd->fd, buf->str + written_total, + buf->len - written_total); + + if (n_written < 0) + { + if (errno == EAGAIN) + break; + if (errno == EINTR) + continue; + + soft_assert (errno == EPIPE); + // Zombies shouldn't get dispatched for writability + hard_assert (!plugin->is_zombie); + + print_debug ("%s: %s", "write", strerror (errno)); + print_error ("failure on writing to plugin `%s'," + " therefore I'm unloading it", plugin->name); + plugin_zombify (plugin); + break; + } + + // This may be equivalent to EAGAIN on some implementations + if (n_written == 0) + break; + + written_total += n_written; + } + + if (written_total != 0) + str_remove_slice (buf, 0, written_total); + + if (buf->len == 0) + // Everything has been written, there's no need to end up in here again + poller_fd_reset (&plugin->write_event); +} + +static void +plugin_queue_write (struct plugin *plugin) +{ + if (plugin->is_zombie) + return; + + // Don't let the write buffer grow indefinitely. If there's a ton of data + // waiting to be processed by the plugin, it usually means there's something + // wrong with it (such as someone stopping the process). + if (plugin->write_buffer.len >= (1 << 20)) + { + print_warning ("plugin `%s' does not seem to process messages fast" + " enough, I'm unloading it", plugin->name); + plugin_zombify (plugin); + return; + } + poller_fd_set (&plugin->write_event, POLLOUT); +} + +static void +plugin_send (struct plugin *plugin, const char *format, ...) + ATTRIBUTE_PRINTF (2, 3); + +static void +plugin_send (struct plugin *plugin, const char *format, ...) +{ + va_list ap; + + if (g_debug_mode) + { + fprintf (stderr, "[%s] <-- \"", plugin->name); + va_start (ap, format); + vfprintf (stderr, format, ap); + va_end (ap); + fputs ("\"\n", stderr); + } + + va_start (ap, format); + str_append_vprintf (&plugin->write_buffer, format, ap); + va_end (ap); + str_append (&plugin->write_buffer, "\r\n"); + + plugin_queue_write (plugin); +} + +static void +plugin_process_ipc (struct plugin *plugin, const struct irc_message *msg) +{ + // Replies are sent in the order in which they came in, so there's + // no need to attach a special identifier to them. It might be + // desirable in some cases, though. + + if (msg->params.len < 1) + return; + + const char *command = msg->params.vector[0]; + if (!plugin->initialized && !strcasecmp (command, "register")) + { + // Register for relaying of IRC traffic + plugin->initialized = true; + + // Flush any queued up traffic here. The point of queuing it in + // the first place is so that we don't have to wait for plugin + // initialization during startup. + // + // Note that if we start filtering data coming to the plugins e.g. + // based on what it tells us upon registration, we might need to + // filter `queued_output' as well. + str_append_str (&plugin->write_buffer, &plugin->queued_output); + str_free (&plugin->queued_output); + + // NOTE: this may trigger the buffer length check + plugin_queue_write (plugin); + } + else if (!strcasecmp (command, "get_config")) + { + if (msg->params.len < 2) + return; + + const char *value = + str_map_find (&plugin->ctx->config, msg->params.vector[1]); + // TODO: escape the value (although there's no need to ATM) + plugin_send (plugin, "%s :%s", + plugin_ipc_command, value ? value : ""); + } + else if (!strcasecmp (command, "print")) + { + if (msg->params.len < 2) + return; + + printf ("%s\n", msg->params.vector[1]); + } +} + +static void +plugin_process_message (const struct irc_message *msg, + const char *raw, void *user_data) +{ + struct plugin *plugin = user_data; + struct bot_context *ctx = plugin->ctx; + + if (g_debug_mode) + fprintf (stderr, "[%s] --> \"%s\"\n", plugin->name, raw); + + if (!strcasecmp (msg->command, plugin_ipc_command)) + plugin_process_ipc (plugin, msg); + else if (plugin->initialized && ctx->irc_registered) + { + // Pass everything else through to the IRC server + // XXX: when the server isn't ready yet, these messages get silently + // discarded, which shouldn't pose a problem most of the time. + // Perhaps we could send a "connected" notification on `register' + // if `irc_ready' is true, or after it becomes true later, so that + // plugins know when to start sending unprovoked IRC messages. + // XXX: another case is when the connection gets interrupted and the + // plugin tries to send something back while we're reconnecting. + // For that we might set up a global buffer that gets flushed out + // after `irc_ready' becomes true. Note that there is always some + // chance of messages getting lost without us even noticing it. + irc_send (ctx, "%s", raw); + } +} + +static void +on_plugin_readable (const struct pollfd *fd, struct plugin *plugin) +{ + if (fd->revents & ~(POLLIN | POLLHUP | POLLERR)) + print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents); + + // TODO: see if I can reuse irc_fill_read_buffer() + struct str *buf = &plugin->read_buffer; + while (true) + { + str_reserve (buf, 512 + 1); + ssize_t n_read = read (fd->fd, buf->str + buf->len, + buf->alloc - buf->len - 1); + + if (n_read < 0) + { + if (errno == EAGAIN) + break; + if (soft_assert (errno == EINTR)) + continue; + + if (!plugin->is_zombie) + { + print_error ("failure on reading from plugin `%s'," + " therefore I'm unloading it", plugin->name); + plugin_zombify (plugin); + } + return; + } + + // EOF; hopefully it will die soon (maybe it already has) + if (n_read == 0) + break; + + buf->str[buf->len += n_read] = '\0'; + if (buf->len >= (1 << 20)) + { + // XXX: this isn't really the best flood prevention mechanism, + // but it wasn't even supposed to be one. + if (plugin->is_zombie) + { + print_error ("a zombie of plugin `%s' is trying to flood us," + " therefore I'm killing it", plugin->name); + kill (plugin->pid, SIGKILL); + } + else + { + print_error ("plugin `%s' seems to spew out data frantically," + " therefore I'm unloading it", plugin->name); + plugin_zombify (plugin); + } + return; + } + } + + irc_process_buffer (buf, plugin_process_message, plugin); +} + +static bool +is_valid_plugin_name (const char *name) +{ + if (!*name) + return false; + for (const char *p = name; *p; p++) + if (!isgraph (*p) || *p == '/') + return false; + return true; +} + +static char * +plugin_resolve_relative_filename (const char *filename) +{ + struct strv paths = strv_make (); + get_xdg_data_dirs (&paths); + strv_append (&paths, PROJECT_DATADIR); + char *result = resolve_relative_filename_generic + (&paths, PROGRAM_NAME "/plugins/", filename); + strv_free (&paths); + return result; +} + +static struct plugin * +plugin_launch (struct bot_context *ctx, const char *name, struct error **e) +{ + char *path = NULL; + const char *plugin_dir = str_map_find (&ctx->config, "plugin_dir"); + if (plugin_dir) + { + // resolve_relative_filename_generic() won't accept relative paths, + // so just keep the old behaviour and expect the file to exist. + // We could use resolve_filename() on "plugin_dir" with paths=getcwd(). + path = xstrdup_printf ("%s/%s", plugin_dir, name); + } + else if (!(path = plugin_resolve_relative_filename (name))) + { + error_set (e, "plugin not found"); + goto fail_0; + } + + int stdin_pipe[2]; + if (pipe (stdin_pipe) == -1) + { + error_set (e, "%s: %s", "pipe", strerror (errno)); + goto fail_0; + } + + int stdout_pipe[2]; + if (pipe (stdout_pipe) == -1) + { + error_set (e, "%s: %s", "pipe", strerror (errno)); + goto fail_1; + } + + struct str work_dir = str_make (); + get_xdg_home_dir (&work_dir, "XDG_DATA_HOME", ".local/share"); + str_append_printf (&work_dir, "/%s", PROGRAM_NAME); + + if (!mkdir_with_parents (work_dir.str, e)) + goto fail_2; + + set_cloexec (stdin_pipe[1]); + set_cloexec (stdout_pipe[0]); + + pid_t pid = fork (); + if (pid == -1) + { + error_set (e, "%s: %s", "fork", strerror (errno)); + goto fail_2; + } + + if (pid == 0) + { + // Redirect the child's stdin and stdout to the pipes + if (dup2 (stdin_pipe[0], STDIN_FILENO) == -1 + || dup2 (stdout_pipe[1], STDOUT_FILENO) == -1) + { + print_error ("%s: %s: %s", "failed to load the plugin", + "dup2", strerror (errno)); + _exit (EXIT_FAILURE); + } + if (chdir (work_dir.str)) + { + print_error ("%s: %s: %s", "failed to load the plugin", + "chdir", strerror (errno)); + _exit (EXIT_FAILURE); + } + + xclose (stdin_pipe[0]); + xclose (stdout_pipe[1]); + + // Restore some of the signal handling + signal (SIGPIPE, SIG_DFL); + + char *argv[] = { path, NULL }; + execve (argv[0], argv, environ); + + // We will collect the failure later via SIGCHLD + print_error ("%s: %s: %s", "failed to load the plugin", + "exec", strerror (errno)); + _exit (EXIT_FAILURE); + } + + str_free (&work_dir); + free (path); + + xclose (stdin_pipe[0]); + xclose (stdout_pipe[1]); + + struct plugin *plugin = plugin_new (); + plugin->ctx = ctx; + plugin->pid = pid; + plugin->name = xstrdup (name); + plugin->read_fd = stdout_pipe[0]; + plugin->write_fd = stdin_pipe[1]; + return plugin; + +fail_2: + str_free (&work_dir); + xclose (stdout_pipe[0]); + xclose (stdout_pipe[1]); +fail_1: + xclose (stdin_pipe[0]); + xclose (stdin_pipe[1]); +fail_0: + free (path); + return NULL; +} + +static bool +plugin_load (struct bot_context *ctx, const char *name, struct error **e) +{ + if (!is_valid_plugin_name (name)) + return error_set (e, "invalid plugin name"); + if (str_map_find (&ctx->plugins_by_name, name)) + return error_set (e, "the plugin has already been loaded"); + + struct plugin *plugin; + if (!(plugin = plugin_launch (ctx, name, e))) + return false; + + set_blocking (plugin->read_fd, false); + set_blocking (plugin->write_fd, false); + + plugin->read_event = poller_fd_make (&ctx->poller, plugin->read_fd); + plugin->read_event.dispatcher = (poller_fd_fn) on_plugin_readable; + plugin->read_event.user_data = plugin; + + plugin->write_event = poller_fd_make (&ctx->poller, plugin->write_fd); + plugin->write_event.dispatcher = (poller_fd_fn) on_plugin_writable; + plugin->write_event.user_data = plugin; + + LIST_PREPEND (ctx->plugins, plugin); + str_map_set (&ctx->plugins_by_name, name, plugin); + + poller_fd_set (&plugin->read_event, POLLIN); + return true; +} + +static bool +plugin_unload (struct bot_context *ctx, const char *name, struct error **e) +{ + struct plugin *plugin = str_map_find (&ctx->plugins_by_name, name); + + if (!plugin) + return error_set (e, "no such plugin is loaded"); + + plugin_zombify (plugin); + + // TODO: add a `kill zombies' command to forcefully get rid of processes + // that do not understand the request. + return true; +} + +static void +plugin_load_all_from_config (struct bot_context *ctx) +{ + const char *plugin_list = str_map_find (&ctx->config, "plugins"); + if (!plugin_list) + return; + + struct strv plugins = strv_make (); + cstr_split (plugin_list, ",", true, &plugins); + for (size_t i = 0; i < plugins.len; i++) + { + char *name = cstr_strip_in_place (plugins.vector[i], " "); + + struct error *e = NULL; + if (!plugin_load (ctx, name, &e)) + { + print_error ("plugin `%s' failed to load: %s", name, e->message); + error_free (e); + } + } + + strv_free (&plugins); +} + +// --- Main program ------------------------------------------------------------ + +static bool +parse_bot_command (const char *s, const char *command, const char **following) +{ + size_t command_len = strlen (command); + if (strncasecmp (s, command, command_len)) + return false; + s += command_len; + + // Expect a word boundary, so that we don't respond to invalid things + if (isalnum (*s)) + return false; + + // Ignore any initial spaces; the rest is the command's argument + while (isblank (*s)) + s++; + *following = s; + return true; +} + +static void +split_bot_command_argument_list (const char *arguments, struct strv *out) +{ + cstr_split (arguments, ",", true, out); + for (size_t i = 0; i < out->len; ) + { + if (!*cstr_strip_in_place (out->vector[i], " \t")) + strv_remove (out, i); + else + i++; + } +} + +static bool +is_private_message (const struct irc_message *msg) +{ + hard_assert (msg->params.len); + return !strchr ("#&+!", *msg->params.vector[0]); +} + +static bool +is_sent_by_admin (struct bot_context *ctx, const struct irc_message *msg) +{ + // No administrator set -> everyone is an administrator + if (!ctx->admin_re) + return true; + return regexec (ctx->admin_re, msg->prefix, 0, NULL, 0) != REG_NOMATCH; +} + +static void respond_to_user (struct bot_context *ctx, const struct + irc_message *msg, const char *format, ...) ATTRIBUTE_PRINTF (3, 4); + +static void +respond_to_user (struct bot_context *ctx, const struct irc_message *msg, + const char *format, ...) +{ + if (!soft_assert (msg->prefix && msg->params.len)) + return; + + char nick[strcspn (msg->prefix, "!") + 1]; + strncpy (nick, msg->prefix, sizeof nick - 1); + nick[sizeof nick - 1] = '\0'; + + va_list ap; + struct str text = str_make (); + va_start (ap, format); + str_append_vprintf (&text, format, ap); + va_end (ap); + + if (is_private_message (msg)) + irc_send (ctx, "PRIVMSG %s :%s", nick, text.str); + else + irc_send (ctx, "PRIVMSG %s :%s: %s", + msg->params.vector[0], nick, text.str); + + str_free (&text); +} + +static void +process_plugin_load (struct bot_context *ctx, + const struct irc_message *msg, const char *name) +{ + struct error *e = NULL; + if (plugin_load (ctx, name, &e)) + respond_to_user (ctx, msg, "plugin `%s' queued for loading", name); + else + { + respond_to_user (ctx, msg, "plugin `%s' could not be loaded: %s", + name, e->message); + error_free (e); + } +} + +static void +process_plugin_unload (struct bot_context *ctx, + const struct irc_message *msg, const char *name) +{ + struct error *e = NULL; + if (plugin_unload (ctx, name, &e)) + respond_to_user (ctx, msg, "plugin `%s' unloaded", name); + else + { + respond_to_user (ctx, msg, "plugin `%s' could not be unloaded: %s", + name, e->message); + error_free (e); + } +} + +static void +process_plugin_reload (struct bot_context *ctx, + const struct irc_message *msg, const char *name) +{ + // XXX: we might want to wait until the plugin terminates before we try + // to reload it (so that it can save its configuration or whatever) + + // So far the only error that can occur is that the plugin hasn't been + // loaded, which in this case doesn't really matter. + plugin_unload (ctx, name, NULL); + + process_plugin_load (ctx, msg, name); +} + +static char * +make_status_report (struct bot_context *ctx) +{ + struct str report = str_make (); + const char *reason = getenv (g_startup_reason_str); + if (!reason) + reason = "launched normally"; + str_append_printf (&report, "\x02startup reason:\x0f %s", reason); + + size_t zombies = 0; + const char *prepend = "; \x02plugins:\x0f "; + for (struct plugin *plugin = ctx->plugins; plugin; plugin = plugin->next) + { + if (plugin->is_zombie) + zombies++; + else + { + str_append_printf (&report, "%s%s", prepend, plugin->name); + prepend = ", "; + } + } + if (!ctx->plugins) + str_append_printf (&report, "%s\x02none\x0f", prepend); + + str_append_printf (&report, "; \x02zombies:\x0f %zu", zombies); + return str_steal (&report); +} + +static void +process_privmsg (struct bot_context *ctx, const struct irc_message *msg) +{ + if (!is_sent_by_admin (ctx, msg)) + return; + if (msg->params.len < 2) + return; + + const char *prefix = str_map_find (&ctx->config, "prefix"); + hard_assert (prefix != NULL); // We have a default value for this + + // For us to recognize the command, it has to start with the prefix, + // with the exception of PM's sent directly to us. + const char *text = msg->params.vector[1]; + if (!strncmp (text, prefix, strlen (prefix))) + text += strlen (prefix); + else if (!is_private_message (msg)) + return; + + const char *following; + struct strv list = strv_make (); + + if (parse_bot_command (text, "quote", &following)) + // This seems to replace tons of random stupid commands + irc_send (ctx, "%s", following); + else if (parse_bot_command (text, "quit", &following)) + { + // We actually need this command (instead of just `quote') because we + // could try to reconnect to the server automatically otherwise. + if (*following) + irc_send (ctx, "QUIT :%s", following); + else + irc_send (ctx, "QUIT"); + initiate_quit (ctx); + } + else if (parse_bot_command (text, "status", &following)) + { + char *report = make_status_report (ctx); + respond_to_user (ctx, msg, "%s", report); + free (report); + } + else if (parse_bot_command (text, "load", &following)) + { + split_bot_command_argument_list (following, &list); + for (size_t i = 0; i < list.len; i++) + process_plugin_load (ctx, msg, list.vector[i]); + } + else if (parse_bot_command (text, "reload", &following)) + { + split_bot_command_argument_list (following, &list); + for (size_t i = 0; i < list.len; i++) + process_plugin_reload (ctx, msg, list.vector[i]); + } + else if (parse_bot_command (text, "unload", &following)) + { + split_bot_command_argument_list (following, &list); + for (size_t i = 0; i < list.len; i++) + process_plugin_unload (ctx, msg, list.vector[i]); + } + + strv_free (&list); +} + +static void +irc_forward_message_to_plugins (struct bot_context *ctx, const char *raw) +{ + // For consistency with plugin_process_message() + if (!ctx->irc_registered) + return; + + for (struct plugin *plugin = ctx->plugins; + plugin; plugin = plugin->next) + { + if (plugin->is_zombie) + continue; + + if (plugin->initialized) + plugin_send (plugin, "%s", raw); + else + // TODO: make sure that this buffer doesn't get too large either + str_append_printf (&plugin->queued_output, "%s\r\n", raw); + } +} + +static void +irc_process_message (const struct irc_message *msg, + const char *raw, void *user_data) +{ + struct bot_context *ctx = user_data; + if (g_debug_mode) + fprintf (stderr, "[%s] ==> \"%s\"\n", "IRC", raw); + + // This should be as minimal as possible, I don't want to have the whole bot + // written in C, especially when I have this overengineered plugin system. + // Therefore the very basic functionality only. + // + // I should probably even rip out the autojoin... + + irc_forward_message_to_plugins (ctx, raw); + + if (!strcasecmp (msg->command, "PING")) + { + if (msg->params.len) + irc_send (ctx, "PONG :%s", msg->params.vector[0]); + else + irc_send (ctx, "PONG"); + } + else if (!ctx->irc_registered && !strcasecmp (msg->command, "001")) + { + print_status ("successfully connected"); + ctx->irc_registered = true; + + const char *autojoin = str_map_find (&ctx->config, "autojoin"); + if (autojoin) + irc_send (ctx, "JOIN :%s", autojoin); + } + else if (!strcasecmp (msg->command, "PRIVMSG")) + process_privmsg (ctx, msg); +} + +enum irc_read_result +{ + IRC_READ_OK, ///< Some data were read successfully + IRC_READ_EOF, ///< The server has closed connection + IRC_READ_AGAIN, ///< No more data at the moment + IRC_READ_ERROR ///< General connection failure +}; + +static enum irc_read_result +irc_fill_read_buffer_tls (struct bot_context *ctx, struct str *buf) +{ + int n_read; +start: + ERR_clear_error (); + n_read = SSL_read (ctx->ssl, buf->str + buf->len, + buf->alloc - buf->len - 1 /* null byte */); + + const char *error_info = NULL; + switch (xssl_get_error (ctx->ssl, n_read, &error_info)) + { + case SSL_ERROR_NONE: + buf->str[buf->len += n_read] = '\0'; + return IRC_READ_OK; + case SSL_ERROR_ZERO_RETURN: + return IRC_READ_EOF; + case SSL_ERROR_WANT_READ: + return IRC_READ_AGAIN; + case SSL_ERROR_WANT_WRITE: + { + // Let it finish the handshake as we don't poll for writability; + // any errors are to be collected by SSL_read() in the next iteration + struct pollfd pfd = { .fd = ctx->irc_fd, .events = POLLOUT }; + soft_assert (poll (&pfd, 1, 0) > 0); + goto start; + } + case XSSL_ERROR_TRY_AGAIN: + goto start; + default: + print_debug ("%s: %s: %s", __func__, "SSL_read", error_info); + return IRC_READ_ERROR; + } +} + +static enum irc_read_result +irc_fill_read_buffer (struct bot_context *ctx, struct str *buf) +{ + ssize_t n_read; +start: + n_read = recv (ctx->irc_fd, buf->str + buf->len, + buf->alloc - buf->len - 1 /* null byte */, 0); + + if (n_read > 0) + { + buf->str[buf->len += n_read] = '\0'; + return IRC_READ_OK; + } + if (n_read == 0) + return IRC_READ_EOF; + + if (errno == EAGAIN) + return IRC_READ_AGAIN; + if (errno == EINTR) + goto start; + + print_debug ("%s: %s: %s", __func__, "recv", strerror (errno)); + return IRC_READ_ERROR; +} + +static bool irc_connect (struct bot_context *, struct error **); +static void irc_queue_reconnect (struct bot_context *); + +static void +irc_cancel_timers (struct bot_context *ctx) +{ + poller_timer_reset (&ctx->timeout_tmr); + poller_timer_reset (&ctx->ping_tmr); + poller_timer_reset (&ctx->reconnect_tmr); +} + +static void +on_irc_reconnect_timeout (void *user_data) +{ + struct bot_context *ctx = user_data; + + struct error *e = NULL; + if (irc_connect (ctx, &e)) + { + // TODO: inform plugins about the new connection + return; + } + + print_error ("%s", e->message); + error_free (e); + irc_queue_reconnect (ctx); +} + +static void +irc_queue_reconnect (struct bot_context *ctx) +{ + hard_assert (ctx->irc_fd == -1); + print_status ("trying to reconnect in %ld seconds...", + ctx->reconnect_delay); + poller_timer_set (&ctx->reconnect_tmr, ctx->reconnect_delay * 1000); +} + +static void +on_irc_disconnected (struct bot_context *ctx) +{ + // Get rid of the dead socket and related things + if (ctx->ssl) + { + SSL_free (ctx->ssl); + ctx->ssl = NULL; + SSL_CTX_free (ctx->ssl_ctx); + ctx->ssl_ctx = NULL; + } + + poller_fd_reset (&ctx->irc_event); + xclose (ctx->irc_fd); + ctx->irc_fd = -1; + ctx->irc_registered = false; + + // TODO: inform plugins about the disconnect event + + // All of our timers have lost their meaning now + irc_cancel_timers (ctx); + + if (ctx->quitting) + try_finish_quit (ctx); + else if (!ctx->reconnect) + initiate_quit (ctx); + else + irc_queue_reconnect (ctx); +} + +static void +on_irc_ping_timeout (void *user_data) +{ + struct bot_context *ctx = user_data; + print_error ("connection timeout"); + on_irc_disconnected (ctx); +} + +static void +on_irc_timeout (void *user_data) +{ + // Provoke a response from the server + struct bot_context *ctx = user_data; + irc_send (ctx, "PING :%s", + (char *) str_map_find (&ctx->config, "nickname")); +} + +static void +irc_reset_connection_timeouts (struct bot_context *ctx) +{ + irc_cancel_timers (ctx); + poller_timer_set (&ctx->timeout_tmr, 3 * 60 * 1000); + poller_timer_set (&ctx->ping_tmr, (3 * 60 + 30) * 1000); +} + +static void +on_irc_readable (const struct pollfd *fd, struct bot_context *ctx) +{ + if (fd->revents & ~(POLLIN | POLLHUP | POLLERR)) + print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents); + + (void) set_blocking (ctx->irc_fd, false); + + struct str *buf = &ctx->read_buffer; + enum irc_read_result (*fill_buffer)(struct bot_context *, struct str *) + = ctx->ssl + ? irc_fill_read_buffer_tls + : irc_fill_read_buffer; + bool disconnected = false; + while (true) + { + str_reserve (buf, 512); + switch (fill_buffer (ctx, buf)) + { + case IRC_READ_AGAIN: + goto end; + case IRC_READ_ERROR: + print_error ("reading from the IRC server failed"); + disconnected = true; + goto end; + case IRC_READ_EOF: + print_status ("the IRC server closed the connection"); + disconnected = true; + goto end; + case IRC_READ_OK: + break; + } + + if (buf->len >= (1 << 20)) + { + print_error ("the IRC server seems to spew out data frantically"); + irc_shutdown (ctx); + goto end; + } + } +end: + (void) set_blocking (ctx->irc_fd, true); + irc_process_buffer (buf, irc_process_message, ctx); + + if (disconnected) + on_irc_disconnected (ctx); + else + irc_reset_connection_timeouts (ctx); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// The bot is currently mostly synchronous (which also makes it shorter), +// however our current SOCKS code is not, hence we must wrap it. + +struct irc_socks_data +{ + struct bot_context *ctx; ///< Bot context + struct poller inner_poller; ///< Special inner poller + bool polling; ///< Inner poller is no longer needed + struct socks_connector connector; ///< SOCKS connector + bool succeeded; ///< Were we successful in connecting? +}; + +static void +irc_on_socks_connected (void *user_data, int socket, const char *hostname) +{ + (void) hostname; + + struct irc_socks_data *data = user_data; + data->ctx->irc_fd = socket; + data->succeeded = true; + data->polling = false; +} + +static void +irc_on_socks_failure (void *user_data) +{ + struct irc_socks_data *data = user_data; + data->succeeded = false; + data->polling = false; +} + +static void +irc_on_socks_connecting (void *user_data, + const char *address, const char *via, const char *version) +{ + (void) user_data; + print_status ("connecting to %s via %s (%s)...", address, via, version); +} + +static void +irc_on_socks_error (void *user_data, const char *error) +{ + (void) user_data; + print_error ("%s: %s", "SOCKS connection failed", error); +} + +static bool +irc_establish_connection_socks (struct bot_context *ctx, + const char *socks_host, const char *socks_port, + const char *host, const char *service, struct error **e) +{ + struct irc_socks_data data; + struct poller *poller = &data.inner_poller; + struct socks_connector *connector = &data.connector; + + data.ctx = ctx; + poller_init (poller); + data.polling = true; + socks_connector_init (connector, poller); + data.succeeded = false; + + connector->on_connected = irc_on_socks_connected; + connector->on_connecting = irc_on_socks_connecting; + connector->on_error = irc_on_socks_error; + connector->on_failure = irc_on_socks_failure; + connector->user_data = &data; + + if (socks_connector_add_target (connector, host, service, e)) + { + socks_connector_run (connector, socks_host, socks_port, + str_map_find (&ctx->config, "socks_username"), + str_map_find (&ctx->config, "socks_password")); + while (data.polling) + poller_run (poller); + if (!data.succeeded) + error_set (e, "connection failed"); + } + + socks_connector_free (connector); + poller_free (poller); + return data.succeeded; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +irc_connect (struct bot_context *ctx, struct error **e) +{ + const char *irc_host = str_map_find (&ctx->config, "irc_host"); + const char *irc_port = str_map_find (&ctx->config, "irc_port"); + const char *socks_host = str_map_find (&ctx->config, "socks_host"); + const char *socks_port = str_map_find (&ctx->config, "socks_port"); + + const char *nickname = str_map_find (&ctx->config, "nickname"); + const char *username = str_map_find (&ctx->config, "username"); + const char *realname = str_map_find (&ctx->config, "realname"); + + // We have a default value for these + hard_assert (irc_port && socks_port); + hard_assert (nickname && username && realname); + + // TODO: again, get rid of `struct error' in here. The question is: how + // do we tell our caller that he should not try to reconnect? + if (!irc_host) + return error_set (e, "no hostname specified in configuration"); + + bool use_tls; + if (!irc_get_boolean_from_config (ctx, "tls", &use_tls, e)) + return false; + + bool connected = socks_host + ? irc_establish_connection_socks (ctx, + socks_host, socks_port, irc_host, irc_port, e) + : irc_establish_connection (ctx, irc_host, irc_port, e); + if (!connected) + return false; + + if (use_tls && !irc_initialize_tls (ctx, e)) + { + xclose (ctx->irc_fd); + ctx->irc_fd = -1; + return false; + } + print_status ("connection established"); + + ctx->irc_event = poller_fd_make (&ctx->poller, ctx->irc_fd); + ctx->irc_event.dispatcher = (poller_fd_fn) on_irc_readable; + ctx->irc_event.user_data = ctx; + + // TODO: in exec try: 1/ set blocking, 2/ setsockopt() SO_LINGER, + // (struct linger) { .l_onoff = true; .l_linger = 1 /* 1s should do */; } + // 3/ /* O_CLOEXEC */ But only if the QUIT message proves unreliable. + poller_fd_set (&ctx->irc_event, POLLIN); + irc_reset_connection_timeouts (ctx); + + irc_send (ctx, "NICK %s", nickname); + irc_send (ctx, "USER %s 8 * :%s", username, realname); + return true; +} + +static bool +parse_config (struct bot_context *ctx, struct error **e) +{ + if (!irc_get_boolean_from_config (ctx, "reconnect", &ctx->reconnect, e)) + return false; + + const char *delay_str = str_map_find (&ctx->config, "reconnect_delay"); + hard_assert (delay_str != NULL); // We have a default value for this + if (!xstrtoul (&ctx->reconnect_delay, delay_str, 10)) + { + return error_set (e, + "invalid configuration value for `%s'", "reconnect_delay"); + } + + hard_assert (!ctx->admin_re); + const char *admin = str_map_find (&ctx->config, "admin"); + if (!admin) + return true; + + struct error *error = NULL; + ctx->admin_re = regex_compile (admin, REG_EXTENDED | REG_NOSUB, &error); + if (!error) + return true; + + error_set (e, "invalid configuration value for `%s': %s", + "admin", error->message); + error_free (error); + return false; +} + +static void +on_plugin_death (struct plugin *plugin, int status) +{ + struct bot_context *ctx = plugin->ctx; + + // TODO: callbacks on children death, so that we may tell the user + // "plugin `name' died"; use `status' + if (!plugin->is_zombie && WIFSIGNALED (status)) + { + const char *notes = ""; +#ifdef WCOREDUMP + if (WCOREDUMP (status)) + notes = " (core dumped)"; +#endif + print_warning ("Plugin `%s' died from signal %d%s", + plugin->name, WTERMSIG (status), notes); + } + + // Let's go through the zombie state to simplify things a bit + // TODO: might not be a completely bad idea to restart the plugin + plugin_zombify (plugin); + + plugin->pid = -1; + + // In theory we could close `read_fd', set `read_event->closed' to true + // and expect epoll to no longer return events for the descriptor, as + // all the pipe ends should be closed by then (the child is dead, so its + // pipe FDs have been closed [assuming it hasn't forked without closing + // the descriptors, which would be evil], and we would have closed all + // of our FDs for this pipe as well). In practice that doesn't work. + poller_fd_reset (&plugin->read_event); + + xclose (plugin->read_fd); + plugin->read_fd = -1; + + LIST_UNLINK (ctx->plugins, plugin); + plugin_destroy (plugin); + + // Living child processes block us from quitting + try_finish_quit (ctx); +} + +static bool +try_reap_plugin (struct bot_context *ctx) +{ + int status; + pid_t zombie = waitpid (-1, &status, WNOHANG); + + if (zombie == -1) + { + // No children to wait on + if (errno == ECHILD) + return false; + + hard_assert (errno == EINTR); + return true; + } + + if (zombie == 0) + return false; + + struct plugin *plugin = plugin_find_by_pid (ctx, zombie); + // XXX: re-exec if something has died that we don't recognize? + if (soft_assert (plugin != NULL)) + on_plugin_death (plugin, status); + return true; +} + +static void +kill_all_zombies (struct bot_context *ctx) +{ + for (struct plugin *plugin = ctx->plugins; plugin; plugin = plugin->next) + { + if (!plugin->is_zombie) + continue; + + print_status ("forcefully killing a zombie of `%s' (PID %d)", + plugin->name, (int) plugin->pid); + kill (plugin->pid, SIGKILL); + } +} + +static void +on_signal_pipe_readable (const struct pollfd *fd, struct bot_context *ctx) +{ + char dummy; + (void) read (fd->fd, &dummy, 1); + + if (g_termination_requested) + { + g_termination_requested = false; + if (!ctx->quitting) + { + // There may be a timer set to reconnect to the server + irc_cancel_timers (ctx); + + if (ctx->irc_fd != -1) + irc_send (ctx, "QUIT :Terminated by signal"); + initiate_quit (ctx); + } + else + // Disregard proper termination, just kill all the children + kill_all_zombies (ctx); + } + + // Reap all dead children (since the signal pipe may overflow etc. we run + // waitpid() in a loop to return all the zombies it knows about). + while (try_reap_plugin (ctx)) + ; +} + +int +main (int argc, char *argv[]) +{ + g_original_argv = strv_make (); + strv_append_vector (&g_original_argv, argv); + + static const struct opt opts[] = + { + { 'd', "debug", NULL, 0, "run in debug mode" }, + { 'h', "help", NULL, 0, "display this help and exit" }, + { 'V', "version", NULL, 0, "output version information and exit" }, + { 'w', "write-default-cfg", "FILENAME", + OPT_OPTIONAL_ARG | OPT_LONG_ONLY, + "write a default configuration file and exit" }, + { 0, NULL, NULL, 0, NULL } + }; + + struct opt_handler oh = + opt_handler_make (argc, argv, opts, NULL, "Modular IRC bot."); + + int c; + while ((c = opt_handler_get (&oh)) != -1) + switch (c) + { + case 'd': + g_debug_mode = true; + break; + case 'h': + opt_handler_usage (&oh, stdout); + exit (EXIT_SUCCESS); + case 'V': + printf (PROGRAM_NAME " " PROGRAM_VERSION "\n"); + exit (EXIT_SUCCESS); + case 'w': + call_simple_config_write_default (optarg, g_config_table); + exit (EXIT_SUCCESS); + default: + print_error ("wrong options"); + opt_handler_usage (&oh, stderr); + exit (EXIT_FAILURE); + } + + opt_handler_free (&oh); + + print_status (PROGRAM_NAME " " PROGRAM_VERSION " starting"); + setup_signal_handlers (); + init_openssl (); + + struct bot_context ctx; + bot_context_init (&ctx); + + struct error *e = NULL; + if (!simple_config_update_from_file (&ctx.config, &e) + || !setup_recovery_handler (&ctx, &e)) + { + print_error ("%s", e->message); + error_free (e); + exit (EXIT_FAILURE); + } + + ctx.signal_event = poller_fd_make (&ctx.poller, g_signal_pipe[0]); + ctx.signal_event.dispatcher = (poller_fd_fn) on_signal_pipe_readable; + ctx.signal_event.user_data = &ctx; + poller_fd_set (&ctx.signal_event, POLLIN); + +#if OpenBSD >= 201605 + // cpath is for creating the plugin home directory + if (pledge ("stdio rpath cpath inet proc exec", NULL)) + exit_fatal ("%s: %s", "pledge", strerror (errno)); +#endif + + plugin_load_all_from_config (&ctx); + if (!parse_config (&ctx, &e) + || !irc_connect (&ctx, &e)) + { + print_error ("%s", e->message); + error_free (e); + exit (EXIT_FAILURE); + } + + // TODO: clean re-exec support; to save the state I can either use argv, + // argp, or I can create a temporary file, unlink it and use the FD + // (mkstemp() on a `struct str' constructed from XDG_RUNTIME_DIR, TMPDIR + // or /tmp as a last resort + PROGRAM_NAME + ".XXXXXX" -> unlink(); + // remember to use O_CREAT | O_EXCL). The state needs to be versioned. + // Unfortunately I cannot de/serialize SSL state. + + ctx.polling = true; + while (ctx.polling) + poller_run (&ctx.poller); + + bot_context_free (&ctx); + strv_free (&g_original_argv); + return EXIT_SUCCESS; +} + diff --git a/xC-gen-proto-c.awk b/xC-gen-proto-c.awk new file mode 100644 index 0000000..2810c96 --- /dev/null +++ b/xC-gen-proto-c.awk @@ -0,0 +1,325 @@ +# xC-gen-proto-c.awk: C backend for xC-gen-proto.awk. +# +# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +# SPDX-License-Identifier: 0BSD +# +# Neither *_new() nor *_destroy() functions are provided, because they'd only +# be useful for top-levels, and are merely extra malloc()/free() calls. +# Users are expected to reuse buffers. +# +# Similarly, no constructors are produced--those are easy to write manually. +# +# All arrays are deserialized zero-terminated, so u8<> and i8<> can be directly +# used as C strings. +# +# All types must be able to dispose partially zero values going from the back, +# i.e., in the reverse order of deserialization. + +function define_internal(name, ctype) { + Types[name] = "internal" + CodegenCType[name] = ctype +} + +function define_int(shortname, ctype) { + define_internal(shortname, ctype) + CodegenSerialize[shortname] = \ + "\tstr_pack_" shortname "(w, %s);\n" + CodegenDeserialize[shortname] = \ + "\tif (!msg_unpacker_" shortname "(r, &%s))\n" \ + "\t\treturn false;\n" +} + +function define_sint(size) { define_int("i" size, "int" size "_t") } +function define_uint(size) { define_int("u" size, "uint" size "_t") } + +function codegen_begin() { + define_sint("8") + define_sint("16") + define_sint("32") + define_sint("64") + define_uint("8") + define_uint("16") + define_uint("32") + define_uint("64") + + define_internal("string", "struct str") + CodegenDispose["string"] = "\tstr_free(&%s);\n" + CodegenSerialize["string"] = \ + "\tif (!proto_string_serialize(&%s, w))\n" \ + "\t\treturn false;\n" + CodegenDeserialize["string"] = \ + "\tif (!proto_string_deserialize(&%s, r))\n" \ + "\t\treturn false;\n" + + define_internal("bool", "bool") + CodegenSerialize["bool"] = \ + "\tstr_pack_u8(w, !!%s);\n" + CodegenDeserialize["bool"] = \ + "\t{\n" \ + "\t\tuint8_t v = 0;\n" \ + "\t\tif (!msg_unpacker_u8(r, &v))\n" \ + "\t\t\treturn false;\n" \ + "\t\t%s = !!v;\n" \ + "\t}\n" + + print "// This file directly depends on liberty.c, but doesn't include it." + + print "" + print "static bool" + print "proto_string_serialize(const struct str *s, struct str *w) {" + print "\tif (s->len > UINT32_MAX)" + print "\t\treturn false;" + print "\tstr_pack_u32(w, s->len);" + print "\tstr_append_str(w, s);" + print "\treturn true;" + print "}" + + print "" + print "static bool" + print "proto_string_deserialize(struct str *s, struct msg_unpacker *r) {" + print "\tuint32_t len = 0;" + print "\tif (!msg_unpacker_u32(r, &len))" + print "\t\treturn false;" + print "\tif (msg_unpacker_get_available(r) < len)" + print "\t\treturn false;" + print "\t*s = str_make();" + print "\tstr_append_data(s, r->data + r->offset, len);" + print "\tr->offset += len;" + print "\tif (!utf8_validate (s->str, s->len))" + print "\t\treturn false;" + print "\treturn true;" + print "}" +} + +function codegen_constant(name, value) { + print "" + print "enum { " PrefixUpper name " = " value " };" +} + +function codegen_enum_value(name, subname, value, cg) { + append(cg, "fields", + "\t" PrefixUpper toupper(cameltosnake(name)) "_" subname \ + " = " value ",\n") +} + +function codegen_enum(name, cg, ctype) { + ctype = "enum " PrefixLower cameltosnake(name) + print "" + print ctype " {" + print cg["fields"] "};" + + # XXX: This should also check if it isn't out-of-range for any reason, + # but our usage of sprintf() stands in the way a bit. + CodegenSerialize[name] = "\tstr_pack_i8(w, %s);\n" + CodegenDeserialize[name] = \ + "\t{\n" \ + "\t\tint8_t v = 0;\n" \ + "\t\tif (!msg_unpacker_i8(r, &v) || !v)\n" \ + "\t\t\treturn false;\n" \ + "\t\t%s = v;\n" \ + "\t}\n" + + CodegenCType[name] = ctype + for (i in cg) + delete cg[i] +} + +function codegen_struct_tag(d, cg, f) { + f = "self->" d["name"] + append(cg, "fields", "\t" CodegenCType[d["type"]] " " d["name"] ";\n") + append(cg, "dispose", sprintf(CodegenDispose[d["type"]], f)) + append(cg, "serialize", sprintf(CodegenSerialize[d["type"]], f)) + # Do not deserialize here, that would be out of order. +} + +function codegen_struct_field(d, cg, f, dispose, serialize, deserialize) { + f = "self->" d["name"] + dispose = CodegenDispose[d["type"]] + serialize = CodegenSerialize[d["type"]] + deserialize = CodegenDeserialize[d["type"]] + if (!d["isarray"]) { + append(cg, "fields", "\t" CodegenCType[d["type"]] " " d["name"] ";\n") + append(cg, "dispose", sprintf(dispose, f)) + append(cg, "serialize", sprintf(serialize, f)) + append(cg, "deserialize", sprintf(deserialize, f)) + return + } + + append(cg, "fields", + "\t" CodegenCType["u32"] " " d["name"] "_len;\n" \ + "\t" CodegenCType[d["type"]] " *" d["name"] ";\n") + + if (dispose) + append(cg, "dispose", "\tif (" f ")\n" \ + "\t\tfor (size_t i = 0; i < " f "_len; i++)\n" \ + indent(indent(sprintf(dispose, f "[i]")))) + append(cg, "dispose", "\tfree(" f ");\n") + + append(cg, "serialize", sprintf(CodegenSerialize["u32"], f "_len")) + if (d["type"] == "u8" || d["type"] == "i8") { + append(cg, "serialize", + "\tstr_append_data(w, " f ", " f "_len);\n") + } else if (serialize) { + append(cg, "serialize", + "\tfor (size_t i = 0; i < " f "_len; i++)\n" \ + indent(sprintf(serialize, f "[i]"))) + } + + append(cg, "deserialize", sprintf(CodegenDeserialize["u32"], f "_len") \ + "\tif (!(" f " = calloc(" f "_len + 1, sizeof *" f ")))\n" \ + "\t\treturn false;\n") + if (d["type"] == "u8" || d["type"] == "i8") { + append(cg, "deserialize", + "\tif (msg_unpacker_get_available(r) < " f "_len)\n" \ + "\t\treturn false;\n" \ + "\tmemcpy(" f ", r->data + r->offset, " f "_len);\n" \ + "\tr->offset += " f "_len;\n") + } else if (deserialize) { + append(cg, "deserialize", + "\tfor (size_t i = 0; i < " f "_len; i++)\n" \ + indent(sprintf(deserialize, f "[i]"))) + } +} + +function codegen_struct(name, cg, ctype, funcname) { + ctype = "struct " PrefixLower cameltosnake(name) + print "" + print ctype " {" + print cg["fields"] "};" + + if (cg["dispose"]) { + funcname = PrefixLower cameltosnake(name) "_free" + print "" + print "static void\n" funcname "(" ctype " *self) {" + print cg["dispose"] "}" + + CodegenDispose[name] = "\t" funcname "(&%s);\n" + } + if (cg["serialize"]) { + funcname = PrefixLower cameltosnake(name) "_serialize" + print "" + print "static bool\n" \ + funcname "(\n\t\t" ctype " *self, struct str *w) {" + print cg["serialize"] "\treturn true;" + print "}" + + CodegenSerialize[name] = "\tif (!" funcname "(&%s, w))\n" \ + "\t\treturn false;\n" + } + if (cg["deserialize"]) { + funcname = PrefixLower cameltosnake(name) "_deserialize" + print "" + print "static bool\n" \ + funcname "(\n\t\t" ctype " *self, struct msg_unpacker *r) {" + print cg["deserialize"] "\treturn true;" + print "}" + + CodegenDeserialize[name] = "\tif (!" funcname "(&%s, r))\n" \ + "\t\treturn false;\n" + } + + CodegenCType[name] = ctype + for (i in cg) + delete cg[i] +} + +function codegen_union_tag(d, cg) { + cg["tagtype"] = d["type"] + cg["tagname"] = d["name"] + append(cg, "fields", "\t" CodegenCType[d["type"]] " " d["name"] ";\n") +} + +function codegen_union_struct( \ + name, casename, cg, scg, structname, fieldname, fullcasename) { + # Don't generate obviously useless structs. + fullcasename = toupper(cameltosnake(cg["tagtype"])) "_" casename + if (!scg["dispose"] && !scg["deserialize"]) { + append(cg, "structless", "\tcase " PrefixUpper fullcasename ":\n") + for (i in scg) + delete scg[i] + return + } + + # And thus not all generated structs are present in Types. + structname = name "_" casename + fieldname = tolower(casename) + codegen_struct(structname, scg) + + append(cg, "fields", "\t" CodegenCType[structname] " " fieldname ";\n") + if (CodegenDispose[structname]) + append(cg, "dispose", "\tcase " PrefixUpper fullcasename ":\n" \ + indent(sprintf(CodegenDispose[structname], "self->" fieldname)) \ + "\t\tbreak;\n") + + # With no de/serialization code, this will simply recognize the tag. + append(cg, "serialize", "\tcase " PrefixUpper fullcasename ":\n" \ + indent(sprintf(CodegenSerialize[structname], "self->" fieldname)) \ + "\t\tbreak;\n") + append(cg, "deserialize", "\tcase " PrefixUpper fullcasename ":\n" \ + indent(sprintf(CodegenDeserialize[structname], "self->" fieldname)) \ + "\t\tbreak;\n") +} + +function codegen_union(name, cg, f, ctype, funcname) { + ctype = "union " PrefixLower cameltosnake(name) + print "" + print ctype " {" + print cg["fields"] "};" + + f = "self->" cg["tagname"] + if (cg["dispose"]) { + funcname = PrefixLower cameltosnake(name) "_free" + print "" + print "static void\n" funcname "(" ctype " *self) {" + print "\tswitch (" f ") {" + if (cg["structless"]) + print cg["structless"] \ + indent(sprintf(CodegenDispose[cg["tagtype"]], f)) "\t\tbreak;" + print cg["dispose"] "\tdefault:" + print "\t\tbreak;" + print "\t}" + print "}" + + CodegenDispose[name] = "\t" funcname "(&%s);\n" + } + if (cg["serialize"]) { + funcname = PrefixLower cameltosnake(name) "_serialize" + print "" + print "static bool\n" \ + funcname "(\n\t\t" ctype " *self, struct str *w) {" + print "\tswitch (" f ") {" + if (cg["structless"]) + print cg["structless"] \ + indent(sprintf(CodegenSerialize[cg["tagtype"]], f)) "\t\tbreak;" + print cg["serialize"] "\tdefault:" + print "\t\treturn false;" + print "\t}" + print "\treturn true;" + print "}" + + CodegenSerialize[name] = "\tif (!" funcname "(&%s, w))\n" \ + "\t\treturn false;\n" + } + if (cg["deserialize"]) { + funcname = PrefixLower cameltosnake(name) "_deserialize" + print "" + print "static bool\n" \ + funcname "(\n\t\t" ctype " *self, struct msg_unpacker *r) {" + print sprintf(CodegenDeserialize[cg["tagtype"]], f) + print "\tswitch (" f ") {" + if (cg["structless"]) + print cg["structless"] "\t\tbreak;" + print cg["deserialize"] "\tdefault:" + print "\t\treturn false;" + print "\t}" + print "\treturn true;" + print "}" + + CodegenDeserialize[name] = "\tif (!" funcname "(&%s, r))\n" \ + "\t\treturn false;\n" + } + + CodegenCType[name] = ctype + for (i in cg) + delete cg[i] +} diff --git a/xC-gen-proto-go.awk b/xC-gen-proto-go.awk new file mode 100644 index 0000000..477a471 --- /dev/null +++ b/xC-gen-proto-go.awk @@ -0,0 +1,519 @@ +# xC-gen-proto-go.awk: Go backend for xC-gen-proto.awk. +# +# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +# SPDX-License-Identifier: 0BSD +# +# This backend also enables proxying to other endpoints using JSON. + +function define_internal(name, gotype) { + Types[name] = "internal" + CodegenGoType[name] = gotype +} + +function define_sint(size, shortname, gotype) { + shortname = "i" size + gotype = "int" size + define_internal(shortname, gotype) + + CodegenAppendJSON[shortname] = \ + "\tb = strconv.AppendInt(b, int64(%s), 10)\n" + if (size == 8) { + CodegenSerialize[shortname] = "\tdata = append(data, uint8(%s))\n" + CodegenDeserialize[shortname] = \ + "\tif len(data) >= 1 {\n" \ + "\t\t%s, data = int8(data[0]), data[1:]\n" \ + "\t} else {\n" \ + "\t\treturn nil, false\n" \ + "\t}\n" + return + } + + CodegenSerialize[shortname] = \ + "\tdata = binary.BigEndian.AppendUint" size "(data, uint" size "(%s))\n" + CodegenDeserialize[shortname] = \ + "\tif len(data) >= " (size / 8) " {\n" \ + "\t\t%s = " gotype "(binary.BigEndian.Uint" size "(data))\n" \ + "\t\tdata = data[" (size / 8) ":]\n" \ + "\t} else {\n" \ + "\t\treturn nil, false\n" \ + "\t}\n" +} + +function define_uint(size, shortname, gotype) { + # Both []byte and []uint8 luckily marshal as base64-encoded JSON strings, + # so there's no need to rename the type as an exception. + shortname = "u" size + gotype = "uint" size + define_internal(shortname, gotype) + + CodegenAppendJSON[shortname] = \ + "\tb = strconv.AppendUint(b, uint64(%s), 10)\n" + if (size == 8) { + CodegenSerialize[shortname] = "\tdata = append(data, %s)\n" + CodegenDeserialize[shortname] = \ + "\tif len(data) >= 1 {\n" \ + "\t\t%s, data = data[0], data[1:]\n" \ + "\t} else {\n" \ + "\t\treturn nil, false\n" \ + "\t}\n" + return + } + + CodegenSerialize[shortname] = \ + "\tdata = binary.BigEndian.AppendUint" size "(data, %s)\n" + CodegenDeserialize[shortname] = \ + "\tif len(data) >= " (size / 8) " {\n" \ + "\t\t%s = binary.BigEndian.Uint" size "(data)\n" \ + "\t\tdata = data[" (size / 8) ":]\n" \ + "\t} else {\n" \ + "\t\treturn nil, false\n" \ + "\t}\n" +} + +function codegen_begin() { + define_sint("8") + define_sint("16") + define_sint("32") + define_sint("64") + define_uint("8") + define_uint("16") + define_uint("32") + define_uint("64") + + define_internal("bool", "bool") + CodegenAppendJSON["bool"] = \ + "\tb = strconv.AppendBool(b, %s)\n" + CodegenSerialize["bool"] = \ + "\tif %s {\n" \ + "\t\tdata = append(data, 1)\n" \ + "\t} else {\n" \ + "\t\tdata = append(data, 0)\n" \ + "\t}\n" + CodegenDeserialize["bool"] = \ + "\tif data, ok = protoConsumeBoolFrom(data, &%s); !ok {\n" \ + "\t\treturn nil, ok\n" \ + "\t}\n" + + define_internal("string", "string") + CodegenSerialize["string"] = \ + "\tif data, ok = protoAppendStringTo(data, %s); !ok {\n" \ + "\t\treturn nil, ok\n" \ + "\t}\n" + CodegenDeserialize["string"] = \ + "\tif data, ok = protoConsumeStringFrom(data, &%s); !ok {\n" \ + "\t\treturn nil, ok\n" \ + "\t}\n" + + print "package main" + print "" + print "import (" + print "\t`encoding/base64`" + print "\t`encoding/binary`" + print "\t`encoding/json`" + print "\t`errors`" + print "\t`math`" + print "\t`strconv`" + print "\t`unicode/utf8`" + print ")" + print "" + + print "// protoConsumeBoolFrom tries to deserialize a boolean value" + print "// from the beginning of a byte stream. When successful," + print "// it returns a subslice with any data that might follow." + print "func protoConsumeBoolFrom(data []byte, b *bool) ([]byte, bool) {" + print "\tif len(data) < 1 {" + print "\t\treturn nil, false" + print "\t}" + print "\tif data[0] != 0 {" + print "\t\t*b = true" + print "\t} else {" + print "\t\t*b = false" + print "\t}" + print "\treturn data[1:], true" + print "}" + print "" + + print "// protoAppendStringTo tries to serialize a string value," + print "// appending it to the end of a byte stream." + print "func protoAppendStringTo(data []byte, s string) ([]byte, bool) {" + print "\tif len(s) > math.MaxUint32 {" + print "\t\treturn nil, false" + print "\t}" + print "\tdata = binary.BigEndian.AppendUint32(data, uint32(len(s)))" + print "\treturn append(data, s...), true" + print "}" + print "" + + print "// protoConsumeStringFrom tries to deserialize a string value" + print "// from the beginning of a byte stream. When successful," + print "// it returns a subslice with any data that might follow." + print "func protoConsumeStringFrom(data []byte, s *string) ([]byte, bool) {" + print "\tif len(data) < 4 {" + print "\t\treturn nil, false" + print "\t}" + print "\tlength := binary.BigEndian.Uint32(data)" + print "\tif data = data[4:]; uint64(len(data)) < uint64(length) {" + print "\t\treturn nil, false" + print "\t}" + print "\t*s = string(data[:length])" + print "\tif !utf8.ValidString(*s) {" + print "\t\treturn nil, false" + print "\t}" + print "\treturn data[length:], true" + print "}" + print "" + + print "// protoUnmarshalEnumJSON converts a JSON fragment to an integer," + print "// ensuring that it's within the expected range of enum values." + print "func protoUnmarshalEnumJSON(data []byte) (int64, error) {" + print "\tvar n int64" + print "\tif err := json.Unmarshal(data, &n); err != nil {" + print "\t\treturn 0, err" + print "\t} else if n > math.MaxInt8 || n < math.MinInt8 {" + print "\t\treturn 0, errors.New(`integer out of range`)" + print "\t} else {" + print "\t\treturn n, nil" + print "\t}" + print "}" + print "" +} + +function codegen_constant(name, value) { + print "const " PrefixCamel snaketocamel(name) " = " value + print "" +} + +function codegen_enum_value(name, subname, value, cg, goname) { + goname = PrefixCamel name snaketocamel(subname) + append(cg, "fields", + "\t" goname " = " value "\n") + append(cg, "stringer", + "\tcase " goname ":\n" \ + "\t\treturn `" snaketocamel(subname) "`\n") + append(cg, "marshal", + goname ",\n") + append(cg, "unmarshal", + "\tcase `" snaketocamel(subname) "`:\n" \ + "\t\t*v = " goname "\n") +} + +function codegen_enum(name, cg, gotype, fields) { + gotype = PrefixCamel name + print "type " gotype " int8" + print "" + + print "const (" + print cg["fields"] ")" + print "" + + print "func (v " gotype ") String() string {" + print "\tswitch v {" + print cg["stringer"] "\tdefault:" + print "\t\treturn strconv.Itoa(int(v))" + print "\t}" + print "}" + print "" + + CodegenIsMarshaler[name] = 1 + fields = cg["marshal"] + sub(/,\n$/, ":", fields) + gsub(/\n/, "\n\t", fields) + print "func (v " gotype ") MarshalJSON() ([]byte, error) {" + print "\tswitch v {" + print indent("case " fields) + print "\t\treturn []byte(`\"` + v.String() + `\"`), nil" + print "\t}" + print "\treturn json.Marshal(int(v))" + print "}" + print "" + + print "func (v *" gotype ") UnmarshalJSON(data []byte) error {" + print "\tvar s string" + print "\tif json.Unmarshal(data, &s) == nil {" + print "\t\t// Handled below." + print "\t} else if n, err := protoUnmarshalEnumJSON(data); err != nil {" + print "\t\treturn err" + print "\t} else {" + print "\t\t*v = " gotype "(n)" + print "\t\treturn nil" + print "\t}" + print "" + print "\tswitch s {" + print cg["unmarshal"] "\tdefault:" + print "\t\treturn errors.New(`unrecognized value: ` + s)" + print "\t}" + print "\treturn nil" + print "}" + print "" + + # XXX: This should also check if it isn't out-of-range for any reason, + # but our usage of sprintf() stands in the way a bit. + CodegenSerialize[name] = "\tdata = append(data, uint8(%s))\n" + CodegenDeserialize[name] = \ + "\tif len(data) >= 1 {\n" \ + "\t\t%s, data = " gotype "(data[0]), data[1:]\n" \ + "\t} else {\n" \ + "\t\treturn nil, false\n" \ + "\t}\n" + + CodegenGoType[name] = gotype + for (i in cg) + delete cg[i] +} + +function codegen_marshal(type, f, marshal) { + if (CodegenAppendJSON[type]) + return sprintf(CodegenAppendJSON[type], f) + + # Complex types are json.Marshalers, there's no need to json.Marshal(&f). + if (CodegenIsMarshaler[type]) + marshal = f ".MarshalJSON()" + else + marshal = "json.Marshal(" f ")" + + return \ + "\tif j, err := " marshal "; err != nil {\n" \ + "\t\treturn nil, err\n" \ + "\t} else {\n" \ + "\t\tb = append(b, j...)\n" \ + "\t}\n" +} + +function codegen_struct_field_marshal(d, cg, camel, f, marshal) { + camel = snaketocamel(d["name"]) + f = "s." camel + if (!d["isarray"]) { + append(cg, "marshal", + "\tb = append(b, `,\"" decapitalize(camel) "\":`...)\n" \ + codegen_marshal(d["type"], f)) + return + } + + # Note that we do not produce `null` for nil slices, unlike encoding/json. + # And arrays never get deserialized as such. + if (d["type"] == "u8") { + append(cg, "marshal", + "\tb = append(b, `,\"" decapitalize(camel) "\":\"`...)\n" \ + "\tb = append(b, base64.StdEncoding.EncodeToString(" f ")...)\n" \ + "\tb = append(b, '\"')\n") + return + } + + append(cg, "marshal", + "\tb = append(b, `,\"" decapitalize(camel) "\":[`...)\n" \ + "\tfor i := 0; i < len(" f "); i++ {\n" \ + "\t\tif i > 0 {\n" \ + "\t\t\tb = append(b, ',')\n" \ + "\t\t}\n" \ + indent(codegen_marshal(d["type"], f "[i]")) \ + "\t}\n" \ + "\tb = append(b, ']')\n") +} + +function codegen_struct_field(d, cg, camel, f, serialize, deserialize) { + codegen_struct_field_marshal(d, cg) + + camel = snaketocamel(d["name"]) + f = "s." camel + serialize = CodegenSerialize[d["type"]] + deserialize = CodegenDeserialize[d["type"]] + if (!d["isarray"]) { + append(cg, "fields", "\t" camel " " CodegenGoType[d["type"]] \ + " `json:\"" decapitalize(camel) "\"`\n") + append(cg, "serialize", sprintf(serialize, f)) + append(cg, "deserialize", sprintf(deserialize, f)) + return + } + + append(cg, "fields", "\t" camel " []" CodegenGoType[d["type"]] \ + " `json:\"" decapitalize(camel) "\"`\n") + + # XXX: This should also check if it isn't out-of-range for any reason. + append(cg, "serialize", + sprintf(CodegenSerialize["u32"], "uint32(len(" f "))")) + if (d["type"] == "u8") { + append(cg, "serialize", + "\tdata = append(data, " f "...)\n") + } else { + append(cg, "serialize", + "\tfor i := 0; i < len(" f "); i++ {\n" \ + indent(sprintf(serialize, f "[i]")) \ + "\t}\n") + } + + append(cg, "deserialize", + "\t{\n" \ + "\t\tvar length uint32\n" \ + indent(sprintf(CodegenDeserialize["u32"], "length"))) + if (d["type"] == "u8") { + append(cg, "deserialize", + "\t\tif uint64(len(data)) < uint64(length) {\n" \ + "\t\t\treturn nil, false\n" \ + "\t\t}\n" \ + "\t\t" f ", data = data[:length], data[length:]\n" \ + "\t}\n") + } else { + append(cg, "deserialize", + "\t\t" f " = make([]" CodegenGoType[d["type"]] ", length)\n" \ + "\t}\n" \ + "\tfor i := 0; i < len(" f "); i++ {\n" \ + indent(sprintf(deserialize, f "[i]")) \ + "\t}\n") + } +} + +function codegen_struct_tag(d, cg, camel, f) { + codegen_struct_field_marshal(d, cg) + + camel = snaketocamel(d["name"]) + f = "s." camel + append(cg, "fields", "\t" camel " " CodegenGoType[d["type"]] \ + " `json:\"" decapitalize(camel) "\"`\n") + append(cg, "serialize", sprintf(CodegenSerialize[d["type"]], f)) + # Do not deserialize here, that is already done by the containing union. +} + +function codegen_struct(name, cg, gotype) { + gotype = PrefixCamel name + print "type " gotype " struct {\n" cg["fields"] "}\n" + + if (cg["marshal"]) { + CodegenIsMarshaler[name] = 1 + print "func (s *" gotype ") MarshalJSON() ([]byte, error) {" + print "\tb := []byte{}" + print cg["marshal"] "\tb[0] = '{'" + print "\treturn append(b, '}'), nil" + print "}" + print "" + } + + if (cg["serialize"]) { + print "func (s *" gotype ") AppendTo(data []byte) ([]byte, bool) {" + print "\tok := true" + print cg["serialize"] "\treturn data, ok" + print "}" + print "" + + CodegenSerialize[name] = \ + "\tif data, ok = %s.AppendTo(data); !ok {\n" \ + "\t\treturn nil, ok\n" \ + "\t}\n" + } + if (cg["deserialize"]) { + print "func (s *" gotype ") ConsumeFrom(data []byte) ([]byte, bool) {" + print "\tok := true" + print cg["deserialize"] "\treturn data, ok" + print "}" + print "" + + CodegenDeserialize[name] = \ + "\tif data, ok = %s.ConsumeFrom(data); !ok {\n" \ + "\t\treturn nil, ok\n" \ + "\t}\n" + } + + CodegenGoType[name] = gotype + for (i in cg) + delete cg[i] +} + +function codegen_union_tag(d, cg) { + cg["tagtype"] = d["type"] + cg["tagname"] = d["name"] + # The tag is implied from the type of struct stored in the interface. +} + +function codegen_union_struct(name, casename, cg, scg, structname, init) { + # And thus not all generated structs are present in Types. + structname = name snaketocamel(casename) + codegen_struct(structname, scg) + + init = CodegenGoType[structname] "{" snaketocamel(cg["tagname"]) \ + ": " decapitalize(snaketocamel(cg["tagname"])) "}" + append(cg, "unmarshal", + "\tcase " CodegenGoType[cg["tagtype"]] snaketocamel(casename) ":\n" \ + "\t\ts := " init "\n" \ + "\t\terr = json.Unmarshal(data, &s)\n" \ + "\t\tu.Interface = &s\n") + append(cg, "serialize", + "\tcase *" CodegenGoType[structname] ":\n" \ + indent(sprintf(CodegenSerialize[structname], "union"))) + append(cg, "deserialize", + "\tcase " CodegenGoType[cg["tagtype"]] snaketocamel(casename) ":\n" \ + "\t\ts := " init "\n" \ + indent(sprintf(CodegenDeserialize[structname], "s")) \ + "\t\tu.Interface = &s\n") +} + +function codegen_union(name, cg, gotype, tagfield, tagvar) { + gotype = PrefixCamel name + print "type " gotype " struct {" + print "\tInterface any" + print "}" + print "" + + # This cannot be a pointer method, it wouldn't work recursively. + CodegenIsMarshaler[name] = 1 + print "func (u " gotype ") MarshalJSON() ([]byte, error) {" + print "\treturn u.Interface.(json.Marshaler).MarshalJSON()" + print "}" + print "" + + tagfield = snaketocamel(cg["tagname"]) + tagvar = decapitalize(tagfield) + print "func (u *" gotype ") UnmarshalJSON(data []byte) (err error) {" + print "\tvar t struct {" + print "\t\t" tagfield " " CodegenGoType[cg["tagtype"]] \ + " `json:\"" tagvar "\"`" + print "\t}" + print "\tif err := json.Unmarshal(data, &t); err != nil {" + print "\t\treturn err" + print "\t}" + print "" + print "\tswitch " tagvar " := t." tagfield "; " tagvar " {" + print cg["unmarshal"] "\tdefault:" + print "\t\terr = errors.New(`unsupported value: ` + " tagvar ".String())" + print "\t}" + print "\treturn err" + print "}" + print "" + + # XXX: Consider changing the interface into an AppendTo/ConsumeFrom one, + # that would eliminate these type case switches entirely. + # On the other hand, it would make it possible to send unsuitable structs. + print "func (u *" gotype ") AppendTo(data []byte) ([]byte, bool) {" + print "\tok := true" + print "\tswitch union := u.Interface.(type) {" + print cg["serialize"] "\tdefault:" + print "\t\treturn nil, false" + print "\t}" + print "\treturn data, ok" + print "}" + print "" + + CodegenSerialize[name] = \ + "\tif data, ok = %s.AppendTo(data); !ok {\n" \ + "\t\treturn nil, ok\n" \ + "\t}\n" + + print "func (u *" gotype ") ConsumeFrom(data []byte) ([]byte, bool) {" + print "\tok := true" + print "\tvar " tagvar " " CodegenGoType[cg["tagtype"]] + print sprintf(CodegenDeserialize[cg["tagtype"]], tagvar) + print "\tswitch " tagvar " {" + print cg["deserialize"] "\tdefault:" + print "\t\treturn nil, false" + print "\t}" + print "\treturn data, ok" + print "}" + print "" + + CodegenDeserialize[name] = \ + "\tif data, ok = %s.ConsumeFrom(data); !ok {\n" \ + "\t\treturn nil, ok\n" \ + "\t}\n" + + CodegenGoType[name] = gotype + for (i in cg) + delete cg[i] +} diff --git a/xC-gen-proto-js.awk b/xC-gen-proto-js.awk new file mode 100644 index 0000000..752fd18 --- /dev/null +++ b/xC-gen-proto-js.awk @@ -0,0 +1,223 @@ +# xC-gen-proto-js.awk: Javascript backend for xC-gen-proto.awk. +# +# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +# SPDX-License-Identifier: 0BSD +# +# This backend is currently for decoding the binary format only. +# (JSON is way too expensive to process and transfer.) +# +# Import the resulting script as a Javascript module. + +function define_internal(name) { + Types[name] = "internal" +} + +function define_sint(size, shortname) { + shortname = "i" size + define_internal(shortname) + CodegenDeserialize[shortname] = "\t%s = r." shortname "()\n" + + print "" + print "\t" shortname "() {" + if (size == "64") { + # XXX: 2^53 - 1 must be enough for anyone. BigInts are a PITA. + print "\t\tconst " shortname \ + " = Number(this.getBigInt" size "(this.offset))" + } else { + print "\t\tconst " shortname " = this.getInt" size "(this.offset)" + } + print "\t\tthis.offset += " (size / 8) + print "\t\treturn " shortname + print "\t}" +} + +function define_uint(size, shortname) { + shortname = "u" size + define_internal(shortname) + CodegenDeserialize[shortname] = "\t%s = r." shortname "()\n" + + print "" + print "\t" shortname "() {" + if (size == "64") { + # XXX: 2^53 - 1 must be enough for anyone. BigInts are a PITA. + print "\t\tconst " shortname \ + " = Number(this.getBigUint" size "(this.offset))" + } else { + print "\t\tconst " shortname " = this.getUint" size "(this.offset)" + } + print "\t\tthis.offset += " (size / 8) + print "\t\treturn " shortname + print "\t}" +} + +function codegen_begin() { + print "export class Reader extends DataView {" + print "\tconstructor() {" + print "\t\tsuper(...arguments)" + print "\t\tthis.offset = 0" + print "\t\tthis.decoder = new TextDecoder('utf-8', {fatal: true})" + print "\t}" + print "" + print "\tget empty() {" + print "\t\treturn this.byteLength <= this.offset" + print "\t}" + print "" + print "\trequire(len) {" + print "\t\tif (this.byteLength - this.offset < len)" + print "\t\t\tthrow `Premature end of data`" + print "\t\treturn this.byteOffset + this.offset" + print "\t}" + + define_internal("string") + CodegenDeserialize["string"] = "\t%s = r.string()\n" + + print "" + print "\tstring() {" + print "\t\tconst len = this.getUint32(this.offset)" + print "\t\tthis.offset += 4" + print "\t\tconst array = new Uint8Array(" + print "\t\t\tthis.buffer, this.require(len), len)" + print "\t\tthis.offset += len" + print "\t\treturn this.decoder.decode(array)" + print "\t}" + + define_internal("bool") + CodegenDeserialize["bool"] = "\t%s = r.bool()\n" + + print "" + print "\tbool() {" + print "\t\tconst u8 = this.getUint8(this.offset)" + print "\t\tthis.offset += 1" + print "\t\treturn u8 != 0" + print "\t}" + + define_sint("8") + define_sint("16") + define_sint("32") + define_sint("64") + define_uint("8") + define_uint("16") + define_uint("32") + define_uint("64") + + print "}" +} + +function codegen_constant(name, value) { + print "" + print "export const " decapitalize(snaketocamel(name)) " = " value +} + +function codegen_enum_value(name, subname, value, cg) { + append(cg, "fields", "\t" snaketocamel(subname) ": " value ",\n") +} + +function codegen_enum(name, cg) { + print "" + print "export const " name " = Object.freeze({" + print cg["fields"] "})" + + CodegenDeserialize[name] = "\t%s = r.i8()\n" + for (i in cg) + delete cg[i] +} + +function codegen_struct_field(d, cg, camel, f, deserialize) { + camel = decapitalize(snaketocamel(d["name"])) + f = "s." camel + append(cg, "fields", "\t" camel "\n") + + deserialize = CodegenDeserialize[d["type"]] + if (!d["isarray"]) { + append(cg, "deserialize", sprintf(deserialize, f)) + return + } + + append(cg, "deserialize", + "\t{\n" \ + indent(sprintf(CodegenDeserialize["u32"], "const len"))) + if (d["type"] == "u8") { + append(cg, "deserialize", + "\t\t" f " = new Uint8Array(\n" \ + "\t\t\tr.buffer, r.require(len), len)\n" \ + "\t\tr.offset += len\n" \ + "\t}\n") + return + } + if (d["type"] == "i8") { + append(cg, "deserialize", + "\t\t" f " = new Int8Array(\n" \ + "\t\t\tr.buffer, r.require(len), len)\n" \ + "\t\tr.offset += len\n" \ + "\t}\n") + return + } + + append(cg, "deserialize", + "\t\t" f " = new Array(len)\n" \ + "\t}\n" \ + "\tfor (let i = 0; i < " f ".length; i++)\n" \ + indent(sprintf(deserialize, f "[i]"))) +} + +function codegen_struct_tag(d, cg) { + append(cg, "fields", "\t" decapitalize(snaketocamel(d["name"])) "\n") + # Do not deserialize here, that is already done by the containing union. +} + +function codegen_struct(name, cg) { + print "" + print "export class " name " {" + print cg["fields"] cg["methods"] + print "\tstatic deserialize(r) {" + print "\t\tconst s = new " name "()" + print indent(cg["deserialize"]) "\t\treturn s" + print "\t}" + print "}" + + CodegenDeserialize[name] = "\t%s = " name ".deserialize(r)\n" + for (i in cg) + delete cg[i] +} + +function codegen_union_tag(d, cg) { + cg["tagtype"] = d["type"] + cg["tagname"] = d["name"] +} + +function codegen_union_struct(name, casename, cg, scg, structname) { + append(scg, "methods", + "\n" \ + "\tconstructor() {\n" \ + "\t\tthis." decapitalize(snaketocamel(cg["tagname"])) \ + " = " cg["tagtype"] "." snaketocamel(casename) "\n" \ + "\t}\n") + + # And thus not all generated structs are present in Types. + structname = name snaketocamel(casename) + codegen_struct(structname, scg) + + append(cg, "deserialize", + "\tcase " cg["tagtype"] "." snaketocamel(casename) ":\n" \ + "\t{\n" \ + indent(sprintf(CodegenDeserialize[structname], "const s")) \ + "\t\treturn s\n" \ + "\t}\n") +} + +function codegen_union(name, cg, tagvar) { + tagvar = decapitalize(snaketocamel(cg["tagname"])) + + print "" + print "export function deserialize" name "(r) {" + print sprintf(CodegenDeserialize[cg["tagtype"]], "const " tagvar) \ + "\tswitch (" tagvar ") {" + print cg["deserialize"] "\tdefault:" + print "\t\tthrow `Unknown " cg["tagtype"] " (${tagvar})`" + print "\t}" + print "}" + + CodegenDeserialize[name] = "\t%s = deserialize" name "(r)\n" + for (i in cg) + delete cg[i] +} diff --git a/xC-gen-proto.awk b/xC-gen-proto.awk new file mode 100644 index 0000000..ad375af --- /dev/null +++ b/xC-gen-proto.awk @@ -0,0 +1,305 @@ +# xC-gen-proto.awk: an XDR-derived code generator for network protocols. +# +# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +# SPDX-License-Identifier: 0BSD +# +# You may read RFC 4506 for context, however it is only a source of inspiration. +# Grammar is easy to deduce from the parser. +# +# Native types: bool, u{8,16,32,64}, i{8,16,32,64}, string +# +# Don't define any new types, unless you hate yourself, then it's okay to do so. +# Backends tend to be a pain in the arse, for different reasons. +# +# All numbers are encoded in big-endian byte order. +# Booleans are one byte each. +# Strings must be valid UTF-8, use u8<> to lift that restriction. +# String and array lengths are encoded as u32. +# Enumeration values automatically start at 1, and are encoded as i8. +# Any struct or union field may be a variable-length array. +# +# Message framing is done externally, but also happens to prefix u32 lengths, +# unless this role is already filled by, e.g., WebSocket. +# +# Usage: env LC_ALL=C awk -f xC-gen-proto.awk -f xC-gen-proto-{c,go,js}.awk \ +# xC-proto > xC-proto.{c,go,js} | {clang-format,gofmt,...} + +# --- Utilities ---------------------------------------------------------------- + +function cameltosnake(s) { + while (match(s, /[[:lower:]][[:upper:]]/)) { + s = substr(s, 1, RSTART) "_" \ + tolower(substr(s, RSTART + 1, RLENGTH - 1)) \ + substr(s, RSTART + RLENGTH) + } + return tolower(s) +} + +function snaketocamel(s) { + s = toupper(substr(s, 1, 1)) tolower(substr(s, 2)) + while (match(s, /_[[:alnum:]]/)) { + s = substr(s, 1, RSTART - 1) \ + toupper(substr(s, RSTART + 1, RLENGTH - 1)) \ + substr(s, RSTART + RLENGTH) + } + return s +} + +function decapitalize(s) { + if (match(s, /[[:upper:]][[:lower:]]/)) { + return tolower(substr(s, 1, 1)) substr(s, 2) + } + return s +} + +function indent(s) { + if (!s) + return s + + gsub(/\n/, "\n\t", s) + sub(/\t*$/, "", s) + return "\t" s +} + +function append(a, key, value) { + a[key] = a[key] value +} + +# --- Parsing ------------------------------------------------------------------ + +function fatal(message) { + print "// " FILENAME ":" FNR ": fatal error: " message + print FILENAME ":" FNR ": fatal error: " message > "/dev/stderr" + exit 1 +} + +function skipcomment() { + do { + if (match($0, /[*][/]/)) { + $0 = substr($0, RSTART + RLENGTH) + return + } + } while (getline > 0) + fatal("unterminated block comment") +} + +function nexttoken() { + do { + if (match($0, /^[[:space:]]+/)) { + $0 = substr($0, RLENGTH + 1) + } else if (match($0, /^[/][/].*/)) { + $0 = "" + } else if (match($0, /^[/][*]/)) { + $0 = substr($0, RLENGTH + 1) + skipcomment() + } else if (match($0, /^[[:alpha:]][[:alnum:]_]*/)) { + Token = substr($0, 1, RLENGTH) + $0 = substr($0, RLENGTH + 1) + return Token + } else if (match($0, /^(0[xX][0-9a-fA-F]+|[1-9][0-9]*)/)) { + Token = substr($0, 1, RLENGTH) + $0 = substr($0, RLENGTH + 1) + return Token + } else if (/./) { + Token = substr($0, 1, 1) + $0 = substr($0, 2) + return Token + } + } while (/./ || getline > 0) + Token = "" + return Token +} + +function expect(v) { + if (!v) + fatal("broken expectations at `" Token "' before `" $0 "'") + return v +} + +function accept(what) { + if (Token != what) + return 0 + nexttoken() + return 1 +} + +function identifier( v) { + if (Token !~ /^[[:alpha:]]/) + return 0 + v = Token + nexttoken() + return v +} + +function number( v) { + if (Token !~ /^[0-9]/) + return 0 + v = Token + nexttoken() + return v +} + +function readnumber( ident) { + ident = identifier() + if (!ident) + return expect(number()) + if (!(ident in Consts)) + fatal("unknown constant: " ident) + return Consts[ident] +} + +function defconst( ident, num) { + if (!accept("const")) + return 0 + + ident = expect(identifier()) + expect(accept("=")) + num = readnumber() + if (ident in Consts) + fatal("constant redefined: " ident) + + Consts[ident] = num + codegen_constant(ident, num) + return 1 +} + +function readtype( ident) { + ident = deftype() + if (ident) + return ident + + ident = identifier() + if (!ident) + return 0 + + if (!(ident in Types)) + fatal("unknown type: " ident) + return ident +} + +function defenum( name, ident, value, cg) { + delete cg[0] + + name = expect(identifier()) + expect(accept("{")) + while (!accept("}")) { + ident = expect(identifier()) + value = value + 1 + if (accept("=")) + value = readnumber() + if (!value) + fatal("enumeration values cannot be zero") + if (value < -128 || value > 127) + fatal("enumeration value out of range") + expect(accept(",")) + append(EnumValues, name, SUBSEP ident) + if (EnumValues[name, ident]++) + fatal("duplicate enum value: " ident) + codegen_enum_value(name, ident, value, cg) + } + + Types[name] = "enum" + codegen_enum(name, cg) + return name +} + +function readfield(out, nonvoid) { + nonvoid = !accept("void") + if (nonvoid) { + out["type"] = expect(readtype()) + out["name"] = expect(identifier()) + # TODO: Consider supporting XDR's VLA length limits here. + # TODO: Consider supporting XDR's fixed-length syntax for string limits. + out["isarray"] = accept("<") && expect(accept(">")) + } + expect(accept(";")) + return nonvoid +} + +function defstruct( name, d, cg) { + delete d[0] + delete cg[0] + + name = expect(identifier()) + expect(accept("{")) + while (!accept("}")) { + if (readfield(d)) + codegen_struct_field(d, cg) + } + + Types[name] = "struct" + codegen_struct(name, cg) + return name +} + +function defunion( name, tag, tagtype, tagvalue, cg, scg, d, a, i, unseen) { + delete cg[0] + delete scg[0] + delete d[0] + + name = expect(identifier()) + expect(accept("switch")) + expect(accept("(")) + tag["type"] = tagtype = expect(readtype()) + tag["name"] = expect(identifier()) + expect(accept(")")) + + if (Types[tagtype] != "enum") + fatal("not an enum type: " tagtype) + codegen_union_tag(tag, cg) + + split(EnumValues[tagtype], a, SUBSEP) + for (i in a) + unseen[a[i]]++ + + expect(accept("{")) + while (!accept("}")) { + if (accept("case")) { + if (tagvalue) + codegen_union_struct(name, tagvalue, cg, scg) + + tagvalue = expect(identifier()) + expect(accept(":")) + if (!unseen[tagvalue]--) + fatal("no such value or duplicate case: " tagtype "." tagvalue) + codegen_struct_tag(tag, scg) + } else if (tagvalue) { + if (readfield(d)) + codegen_struct_field(d, scg) + } else { + fatal("union fields must fall under a case") + } + } + if (tagvalue) + codegen_union_struct(name, tagvalue, cg, scg) + + # What remains non-zero in unseen[2..] is simply not recognized/allowed. + Types[name] = "union" + codegen_union(name, cg) + return name +} + +function deftype() { + if (accept("enum")) + return defenum() + if (accept("struct")) + return defstruct() + if (accept("union")) + return defunion() + return 0 +} + +BEGIN { + PrefixLower = "relay_" + PrefixUpper = "RELAY_" + PrefixCamel = "Relay" + + print "// Generated by xC-gen-proto.awk. DO NOT MODIFY." + codegen_begin() + + nexttoken() + while (Token != "") { + expect(defconst() || deftype()) + expect(accept(";")) + } +} diff --git a/xC-proto b/xC-proto new file mode 100644 index 0000000..3057404 --- /dev/null +++ b/xC-proto @@ -0,0 +1,206 @@ +// Backwards-compatible protocol version. +const VERSION = 1; + +// From the frontend to the relay. +struct CommandMessage { + // The command sequence number will be repeated in responses + // in the respective fields. + u32 command_seq; + union CommandData switch (enum Command { + HELLO, + ACTIVE, + BUFFER_ACTIVATE, + BUFFER_INPUT, + BUFFER_TOGGLE_UNIMPORTANT, + PING_RESPONSE, + PING, + BUFFER_COMPLETE, + BUFFER_LOG, + } command) { + // If the version check succeeds, the client will receive + // an initial stream of SERVER_UPDATE, BUFFER_UPDATE, BUFFER_STATS, + // BUFFER_LINE, and finally a BUFFER_ACTIVATE message. + case HELLO: + u32 version; + case ACTIVE: + void; + case BUFFER_ACTIVATE: + string buffer_name; + case BUFFER_INPUT: + string buffer_name; + string text; + // XXX: Perhaps this should rather be handled through a /buffer command. + case BUFFER_TOGGLE_UNIMPORTANT: + string buffer_name; + case PING_RESPONSE: + u32 event_seq; + + // Only these commands may produce Event.RESPONSE, as below, + // but any command may produce an error. + case PING: + void; + case BUFFER_COMPLETE: + string buffer_name; + string text; + u32 position; + case BUFFER_LOG: + string buffer_name; + } data; +}; + +// From the relay to the frontend. +struct EventMessage { + u32 event_seq; + union EventData switch (enum Event { + PING, + BUFFER_LINE, + BUFFER_UPDATE, + BUFFER_STATS, + BUFFER_RENAME, + BUFFER_REMOVE, + BUFFER_ACTIVATE, + BUFFER_CLEAR, + SERVER_UPDATE, + SERVER_RENAME, + SERVER_REMOVE, + ERROR, + RESPONSE, + } event) { + case PING: + void; + + case BUFFER_LINE: + string buffer_name; + // Whether the line should also be displayed in the active buffer. + bool leak_to_active; + bool is_unimportant; + bool is_highlight; + enum Rendition { + BARE, + INDENT, + STATUS, + ERROR, + JOIN, + PART, + ACTION, + } rendition; + // Unix timestamp in milliseconds. + u64 when; + // Broken-up text, with in-band formatting. + union ItemData switch (enum Item { + TEXT, + RESET, + FG_COLOR, + BG_COLOR, + FLIP_BOLD, + FLIP_ITALIC, + FLIP_UNDERLINE, + FLIP_INVERSE, + FLIP_CROSSED_OUT, + FLIP_MONOSPACE, + } kind) { + case TEXT: + string text; + case RESET: + void; + case FG_COLOR: + i16 color; + case BG_COLOR: + i16 color; + case FLIP_BOLD: + case FLIP_ITALIC: + case FLIP_UNDERLINE: + case FLIP_INVERSE: + case FLIP_CROSSED_OUT: + case FLIP_MONOSPACE: + void; + } items<>; + case BUFFER_UPDATE: + string buffer_name; + bool hide_unimportant; + union BufferContext switch (enum BufferKind { + GLOBAL, + SERVER, + CHANNEL, + PRIVATE_MESSAGE, + } kind) { + case GLOBAL: + void; + case SERVER: + string server_name; + case CHANNEL: + string server_name; + ItemData topic<>; + // This includes parameters, separated by spaces. + string modes; + case PRIVATE_MESSAGE: + string server_name; + } context; + case BUFFER_STATS: + string buffer_name; + // These are cumulative, even for lines flushed out from buffers. + // Updates to these values aren't broadcasted, thus handle: + // - BUFFER_LINE by bumping/setting them as appropriate, + // - BUFFER_ACTIVATE by clearing them for the previous buffer + // (this way, they can be used to mark unread messages). + u32 new_messages; + u32 new_unimportant_messages; + bool highlighted; + case BUFFER_RENAME: + string buffer_name; + string new; + case BUFFER_REMOVE: + string buffer_name; + case BUFFER_ACTIVATE: + string buffer_name; + case BUFFER_CLEAR: + string buffer_name; + + case SERVER_UPDATE: + string server_name; + union ServerData switch (enum ServerState { + DISCONNECTED, + CONNECTING, + CONNECTED, + REGISTERED, + DISCONNECTING, + } state) { + case DISCONNECTED: + case CONNECTING: + case CONNECTED: + void; + case REGISTERED: + string user; + string user_modes; + // Theoretically, we could also send user information in this state, + // but we'd have to duplicate both fields. + case DISCONNECTING: + void; + } data; + case SERVER_RENAME: + // Buffers aren't sent updates for in this circumstance, + // as that wouldn't be sufficiently atomic anyway. + string server_name; + string new; + case SERVER_REMOVE: + string server_name; + + // Restriction: command_seq strictly follows the sequence received + // by the relay, across both of these replies. + case ERROR: + u32 command_seq; + string error; + case RESPONSE: + u32 command_seq; + union ResponseData switch (Command command) { + case PING: + void; + case BUFFER_COMPLETE: + u32 start; + string completions<>; + case BUFFER_LOG: + // UTF-8, but not guaranteed. + u8 log<>; + } data; + } data; +}; @@ -0,0 +1,127 @@ +xC(1) +===== +:doctype: manpage +:manmanual: xK Manual +:mansource: xK {release-version} + +Name +---- +xC - terminal-based IRC client + +Synopsis +-------- +*xC* [_OPTION_]... + +Description +----------- +*xC* is a scriptable IRC client for the command line. On the first run it will +welcome you with an introductory message. Should you ever get lost, use the +*/help* command to obtain more information on commands or options. + +Options +------- +*-f*, *--format*:: + Format IRC text from the standard input, converting colour sequences and + other formatting marks to ANSI codes retrieved from the *terminfo*(5) + database: ++ +.... +printf '\x02bold\x02\n' | xC -f +.... ++ +This feature may be used to preview server MOTD files. + +*-h*, *--help*:: + Display a help message and exit. + +*-V*, *--version*:: + Output version information and exit. + +Key bindings +------------ +Most key bindings are inherited from the frontend in use, which is either GNU +Readline or BSD editline. A few of them, however, are special to the IRC client +or assume a different function. This is a list of all local overrides and +their respective function names: + +*M-p*:: + Go up in history for this buffer (normally mapped to *C-p*). +*M-n*:: + Go down in history for this buffer (normally mapped to *C-n*). +*C-p*, *F5*: *previous-buffer*:: + Switch to the previous buffer in order. +*C-n*, *F6*: *next-buffer*:: + Switch to the next buffer in order. +*M-TAB*: *switch-buffer*:: + Switch to the last buffer, i.e., the one you were in before. +*M-0*, *M-1*, ..., *M-9*: *goto-buffer*:: + Go to the N-th buffer (normally sets a repeat counter). + Since there is no buffer number zero, *M-0* goes to the tenth one. +*M-!*: *goto-highlight*:: + Go to the first following buffer with an unseen highlight. +*M-a*: *goto-activity*:: + Go to the first following buffer with unseen activity. +*PageUp*: *display-backlog*:: + Show the in-memory backlog for this buffer using *general.pager*, + which is almost certainly the *less*(1) program. +*M-h*: *display-full-log*:: + Show the log file for this buffer using *general.pager*. +*M-H*: *toggle-unimportant*:: + Hide all join, part and quit messages, as well as all channel mode changes + that only relate to user channel modes. Intended to reduce noise in + channels with lots of people. +*M-e*: *edit-input*:: + Run an editor on the command line, making it easy to edit multiline + messages. Remember to save the file before exit. +*M-m*: *insert-attribute*:: + The next key will be interpreted as a formatting mark to insert: + *c* for colours (optionally followed by numbers for the foreground + and background), *i* for italics, *b* for bold text, *u* for underlined, + *s* for struck-through, *m* for monospace, *v* for inverse text, + and *o* resets all formatting. +*C-l*: *redraw-screen*:: + Should there be any issues with the display, this will clear the terminal + screen and redraw all information. + +Additionally, *C-w* and *C-u* in editline behave the same as they would in +Readline or the "vi" command mode, even though the "emacs" mode is enabled +by default. + +Bindings can be customized in your _.inputrc_ or _.editrc_ file. Both libraries +support conditional execution based on the program name. Beware that it is easy +to make breaking changes. + +Environment +----------- +*VISUAL*, *EDITOR*:: + The editor program to be launched by the *edit-input* function. + If neither variable is set, it defaults to *vi*(1). + +Files +----- +*xC* follows the XDG Base Directory Specification. + +_~/.config/xC/xC.conf_:: + The program's configuration file. Preferrably use internal facilities, such + as the */set* command, to make changes in it. + +_~/.local/share/xC/logs/_:: + When enabled by *general.logging*, log files are stored here. + +_~/.local/share/xC/plugins/_:: +_/usr/local/share/xC/plugins/_:: +_/usr/share/xC/plugins/_:: + Plugins are searched for in these directories, in order. + +Bugs +---- +The editline (libedit) frontend may exhibit some unexpected behaviour. + +Reporting bugs +-------------- +Use https://git.janouch.name/p/xK to report bugs, request features, +or submit pull requests. + +See also +-------- +*less*(1), *readline*(3) or *editline*(7) @@ -0,0 +1,15971 @@ +/* + * xC.c: a terminal-based IRC client + * + * Copyright (c) 2015 - 2022, Přemysl Eric Janouch <p@janouch.name> + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +// A table of all attributes we use for output +#define ATTR_TABLE(XX) \ + XX( PROMPT, prompt, Terminal attrs for the prompt ) \ + XX( DATE_CHANGE, date_change, Terminal attrs for date change ) \ + XX( READ_MARKER, read_marker, Terminal attrs for the read marker ) \ + XX( WARNING, warning, Terminal attrs for warnings ) \ + XX( ERROR, error, Terminal attrs for errors ) \ + XX( EXTERNAL, external, Terminal attrs for external lines ) \ + XX( TIMESTAMP, timestamp, Terminal attrs for timestamps ) \ + XX( HIGHLIGHT, highlight, Terminal attrs for highlights ) \ + XX( ACTION, action, Terminal attrs for user actions ) \ + XX( USERHOST, userhost, Terminal attrs for user@host ) \ + XX( JOIN, join, Terminal attrs for joins ) \ + XX( PART, part, Terminal attrs for parts ) + +enum +{ + ATTR_RESET, +#define XX(x, y, z) ATTR_ ## x, + ATTR_TABLE (XX) +#undef XX + ATTR_COUNT +}; + +// User data for logger functions to enable formatted logging +#define print_fatal_data ((void *) ATTR_ERROR) +#define print_error_data ((void *) ATTR_ERROR) +#define print_warning_data ((void *) ATTR_WARNING) + +#include "config.h" +#define PROGRAM_NAME "xC" + +// fmemopen +#define _POSIX_C_SOURCE 200809L + +#include "common.c" +#include "xD-replies.c" +#include "xC-proto.c" + +#include <math.h> +#include <langinfo.h> +#include <locale.h> +#include <pwd.h> +#include <sys/utsname.h> +#include <wchar.h> + +#include <termios.h> +#include <sys/ioctl.h> + +#include <curses.h> +#include <term.h> + +// Literally cancer +#undef lines +#undef columns + +#include <ffi.h> + +#ifdef HAVE_LUA +#include <lua.h> +#include <lualib.h> +#include <lauxlib.h> +#endif // HAVE_LUA + +// --- Terminal information ---------------------------------------------------- + +static struct +{ + bool initialized; ///< Terminal is available + bool stdout_is_tty; ///< `stdout' is a terminal + bool stderr_is_tty; ///< `stderr' is a terminal + + struct termios termios; ///< Terminal attributes + char *color_set_fg[256]; ///< Codes to set the foreground colour + char *color_set_bg[256]; ///< Codes to set the background colour + + int lines; ///< Number of lines + int columns; ///< Number of columns +} +g_terminal; + +static void +update_screen_size (void) +{ +#ifdef TIOCGWINSZ + if (!g_terminal.stdout_is_tty) + return; + + struct winsize size; + if (!ioctl (STDOUT_FILENO, TIOCGWINSZ, (char *) &size)) + { + char *row = getenv ("LINES"); + char *col = getenv ("COLUMNS"); + unsigned long tmp; + g_terminal.lines = + (row && xstrtoul (&tmp, row, 10)) ? tmp : size.ws_row; + g_terminal.columns = + (col && xstrtoul (&tmp, col, 10)) ? tmp : size.ws_col; + } +#endif // TIOCGWINSZ +} + +static bool +init_terminal (void) +{ + int tty_fd = -1; + if ((g_terminal.stderr_is_tty = isatty (STDERR_FILENO))) + tty_fd = STDERR_FILENO; + if ((g_terminal.stdout_is_tty = isatty (STDOUT_FILENO))) + tty_fd = STDOUT_FILENO; + + int err; + if (tty_fd == -1 || setupterm (NULL, tty_fd, &err) == ERR) + return false; + + // Make sure all terminal features used by us are supported + if (!set_a_foreground || !set_a_background + || !enter_bold_mode || !exit_attribute_mode + || tcgetattr (tty_fd, &g_terminal.termios)) + { + del_curterm (cur_term); + return false; + } + + // Make sure newlines are output correctly + g_terminal.termios.c_oflag |= ONLCR; + (void) tcsetattr (tty_fd, TCSADRAIN, &g_terminal.termios); + + g_terminal.lines = tigetnum ("lines"); + g_terminal.columns = tigetnum ("cols"); + update_screen_size (); + + int max = MIN (256, max_colors); + for (int i = 0; i < max; i++) + { + g_terminal.color_set_fg[i] = xstrdup (tparm (set_a_foreground, + i, 0, 0, 0, 0, 0, 0, 0, 0)); + g_terminal.color_set_bg[i] = xstrdup (tparm (set_a_background, + i, 0, 0, 0, 0, 0, 0, 0, 0)); + } + return g_terminal.initialized = true; +} + +static void +free_terminal (void) +{ + if (!g_terminal.initialized) + return; + + for (int i = 0; i < 256; i++) + { + free (g_terminal.color_set_fg[i]); + free (g_terminal.color_set_bg[i]); + } + del_curterm (cur_term); +} + +// --- User interface ---------------------------------------------------------- + +// I'm not sure which one of these backends is worse: whether it's GNU Readline +// or BSD Editline. They both have their own annoying problems. We use lots +// of hacks to get the results we want and need. +// +// The abstraction is a necessary evil. It's still not 100%, though. + +/// Some arbitrary limit for the history +#define HISTORY_LIMIT 10000 + +/// Characters that separate words +#define WORD_BREAKING_CHARS " \f\n\r\t\v" + +struct input +{ + struct input_vtable *vtable; ///< Virtual methods + void (*add_functions) (void *); ///< Define functions for binding + void *user_data; ///< User data for callbacks +}; + +typedef void *input_buffer_t; ///< Pointer alias for input buffers + +/// Named function that can be bound to a sequence of characters +typedef bool (*input_fn) (int count, int key, void *user_data); + +// A little bit better than tons of forwarder functions in our case +#define CALL(self, name) ((self)->vtable->name ((self))) +#define CALL_(self, name, ...) ((self)->vtable->name ((self), __VA_ARGS__)) + +struct input_vtable +{ + /// Start the interface under the given program name + void (*start) (void *input, const char *program_name); + /// Stop the interface + void (*stop) (void *input); + /// Prepare or unprepare terminal for our needs + void (*prepare) (void *input, bool enabled); + /// Destroy the object + void (*destroy) (void *input); + + /// Hide the prompt if shown + void (*hide) (void *input); + /// Show the prompt if hidden + void (*show) (void *input); + /// Retrieve current prompt string + const char *(*get_prompt) (void *input); + /// Change the prompt string; takes ownership + void (*set_prompt) (void *input, char *prompt); + /// Ring the terminal bell + void (*ding) (void *input); + + /// Create a new input buffer + input_buffer_t (*buffer_new) (void *input); + /// Destroy an input buffer + void (*buffer_destroy) (void *input, input_buffer_t buffer); + /// Switch to a different input buffer + void (*buffer_switch) (void *input, input_buffer_t buffer); + + /// Register a function that can be bound to character sequences + void (*register_fn) (void *input, + const char *name, const char *help, input_fn fn, void *user_data); + /// Bind an arbitrary sequence of characters to the given named function + void (*bind) (void *input, const char *seq, const char *fn); + /// Bind Ctrl+key to the given named function + void (*bind_control) (void *input, char key, const char *fn); + /// Bind Alt+key to the given named function + void (*bind_meta) (void *input, char key, const char *fn); + + /// Get the current line input and position within + char *(*get_line) (void *input, int *position); + /// Clear the current line input + void (*clear_line) (void *input); + /// Insert text at current position + bool (*insert) (void *input, const char *text); + + /// Handle terminal resize + void (*on_tty_resized) (void *input); + /// Handle terminal input + void (*on_tty_readable) (void *input); +}; + +#define INPUT_VTABLE(XX) \ + XX (start) XX (stop) XX (prepare) XX (destroy) \ + XX (hide) XX (show) XX (get_prompt) XX (set_prompt) XX (ding) \ + XX (buffer_new) XX (buffer_destroy) XX (buffer_switch) \ + XX (register_fn) XX (bind) XX (bind_control) XX (bind_meta) \ + XX (get_line) XX (clear_line) XX (insert) \ + XX (on_tty_resized) XX (on_tty_readable) + +// ~~~ GNU Readline ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#ifdef HAVE_READLINE + +#include <readline/readline.h> +#include <readline/history.h> + +#define INPUT_START_IGNORE RL_PROMPT_START_IGNORE +#define INPUT_END_IGNORE RL_PROMPT_END_IGNORE + +struct input_rl_fn +{ + ffi_closure closure; ///< Closure + + LIST_HEADER (struct input_rl_fn) + input_fn callback; ///< Real callback + void *user_data; ///< Real callback user data +}; + +struct input_rl_buffer +{ + HISTORY_STATE *history; ///< Saved history state + char *saved_line; ///< Saved line content + int saved_point; ///< Saved cursor position + int saved_mark; ///< Saved mark +}; + +struct input_rl +{ + struct input super; ///< Parent class + + bool active; ///< Interface has been started + char *prompt; ///< The prompt we use + int prompt_shown; ///< Whether the prompt is shown now + + char *saved_line; ///< Saved line content + int saved_point; ///< Saved cursor position + int saved_mark; ///< Saved mark + + struct input_rl_fn *fns; ///< Named functions + struct input_rl_buffer *current; ///< Current input buffer +}; + +static void +input_rl_ding (void *input) +{ + (void) input; + rl_ding (); +} + +static const char * +input_rl_get_prompt (void *input) +{ + struct input_rl *self = input; + return self->prompt; +} + +static void +input_rl_set_prompt (void *input, char *prompt) +{ + struct input_rl *self = input; + cstr_set (&self->prompt, prompt); + + if (!self->active || self->prompt_shown <= 0) + return; + + // First reset the prompt to work around a bug in readline + rl_set_prompt (""); + rl_redisplay (); + + rl_set_prompt (self->prompt); + rl_redisplay (); +} + +static void +input_rl_clear_line (void *input) +{ + (void) input; + rl_replace_line ("", false); + rl_redisplay (); +} + +static void +input_rl__erase (struct input_rl *self) +{ + rl_set_prompt (""); + input_rl_clear_line (self); +} + +static bool +input_rl_insert (void *input, const char *s) +{ + struct input_rl *self = input; + rl_insert_text (s); + if (self->prompt_shown > 0) + rl_redisplay (); + + // GNU Readline, contrary to Editline, doesn't care about validity + return true; +} + +static char * +input_rl_get_line (void *input, int *position) +{ + (void) input; + if (position) *position = rl_point; + return rl_copy_text (0, rl_end); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_rl_bind (void *input, const char *seq, const char *function_name) +{ + (void) input; + rl_bind_keyseq (seq, rl_named_function (function_name)); +} + +static void +input_rl_bind_meta (void *input, char key, const char *function_name) +{ + // This one seems to actually work + char keyseq[] = { '\\', 'e', key, 0 }; + input_rl_bind (input, keyseq, function_name); +#if 0 + // While this one only fucks up UTF-8 + // Tested with urxvt and xterm, on Debian Jessie/Arch, default settings + // \M-<key> behaves exactly the same + rl_bind_key (META (key), rl_named_function (function_name)); +#endif +} + +static void +input_rl_bind_control (void *input, char key, const char *function_name) +{ + char keyseq[] = { '\\', 'C', '-', key, 0 }; + input_rl_bind (input, keyseq, function_name); +} + +static void +input_rl__forward (ffi_cif *cif, void *ret, void **args, void *user_data) +{ + (void) cif; + + struct input_rl_fn *data = user_data; + if (!data->callback + (*(int *) args[0], UNMETA (*(int *) args[1]), data->user_data)) + rl_ding (); + *(int *) ret = 0; +} + +static void +input_rl_register_fn (void *input, + const char *name, const char *help, input_fn callback, void *user_data) +{ + struct input_rl *self = input; + (void) help; + + void *bound_fn = NULL; + struct input_rl_fn *data = ffi_closure_alloc (sizeof *data, &bound_fn); + hard_assert (data); + + static ffi_cif cif; + static ffi_type *args[2] = { &ffi_type_sint, &ffi_type_sint }; + hard_assert (ffi_prep_cif + (&cif, FFI_DEFAULT_ABI, 2, &ffi_type_sint, args) == FFI_OK); + + data->prev = data->next = NULL; + data->callback = callback; + data->user_data = user_data; + hard_assert (ffi_prep_closure_loc (&data->closure, + &cif, input_rl__forward, data, bound_fn) == FFI_OK); + + rl_add_defun (name, (rl_command_func_t *) bound_fn, -1); + LIST_PREPEND (self->fns, data); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int app_readline_init (void); +static void on_readline_input (char *line); +static char **app_readline_completion (const char *text, int start, int end); + +static void +input_rl_start (void *input, const char *program_name) +{ + struct input_rl *self = input; + using_history (); + // This can cause memory leaks, or maybe even a segfault. Funny, eh? + stifle_history (HISTORY_LIMIT); + + const char *slash = strrchr (program_name, '/'); + rl_readline_name = slash ? ++slash : program_name; + rl_startup_hook = app_readline_init; + rl_catch_sigwinch = false; + rl_change_environment = false; + + rl_basic_word_break_characters = WORD_BREAKING_CHARS; + rl_completer_word_break_characters = NULL; + rl_attempted_completion_function = app_readline_completion; + + // We shouldn't produce any duplicates that the library would help us + // autofilter, and we don't generally want alphabetic ordering at all + rl_sort_completion_matches = false; + + hard_assert (self->prompt != NULL); + // The inputrc is read before any callbacks are called, so we need to + // register all functions that our user may want to map up front + self->super.add_functions (self->super.user_data); + rl_callback_handler_install (self->prompt, on_readline_input); + + self->prompt_shown = 1; + self->active = true; +} + +static void +input_rl_stop (void *input) +{ + struct input_rl *self = input; + if (self->prompt_shown > 0) + input_rl__erase (self); + + // This is okay as long as we're not called from within readline + rl_callback_handler_remove (); + self->active = false; + self->prompt_shown = false; +} + +static void +input_rl_prepare (void *input, bool enabled) +{ + (void) input; + if (enabled) + rl_prep_terminal (true); + else + rl_deprep_terminal (); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// The following part shows you why it's not a good idea to use +// GNU Readline for this kind of software. Or for anything else, really. + +static void +input_rl__save_buffer (struct input_rl *self, struct input_rl_buffer *buffer) +{ + (void) self; + + buffer->history = history_get_history_state (); + buffer->saved_line = rl_copy_text (0, rl_end); + buffer->saved_point = rl_point; + buffer->saved_mark = rl_mark; + + rl_replace_line ("", true); + if (self->prompt_shown > 0) + rl_redisplay (); +} + +static void +input_rl__restore_buffer (struct input_rl *self, struct input_rl_buffer *buffer) +{ + if (buffer->history) + { + // history_get_history_state() just allocates a new HISTORY_STATE + // and fills it with its current internal data. We don't need that + // shell anymore after reviving it. + history_set_history_state (buffer->history); + free (buffer->history); + buffer->history = NULL; + } + else + { + // This should get us a clean history while keeping the flags. + // Note that we've either saved the previous history entries, or we've + // cleared them altogether, so there should be nothing to leak. + HISTORY_STATE *state = history_get_history_state (); + state->offset = state->length = state->size = 0; + state->entries = NULL; + history_set_history_state (state); + free (state); + } + + if (buffer->saved_line) + { + rl_replace_line (buffer->saved_line, true); + rl_point = buffer->saved_point; + rl_mark = buffer->saved_mark; + cstr_set (&buffer->saved_line, NULL); + + if (self->prompt_shown > 0) + rl_redisplay (); + } +} + +static void +input_rl_buffer_switch (void *input, input_buffer_t input_buffer) +{ + struct input_rl *self = input; + struct input_rl_buffer *buffer = input_buffer; + // There could possibly be occurences of the current undo list in some + // history entry. We either need to free the undo list, or move it + // somewhere else to load back later, as the buffer we're switching to + // has its own history state. + rl_free_undo_list (); + + // Save this buffer's history so that it's independent for each buffer + if (self->current) + input_rl__save_buffer (self, self->current); + else + // Just throw it away; there should always be an active buffer however +#if RL_READLINE_VERSION >= 0x0603 + rl_clear_history (); +#else // RL_READLINE_VERSION < 0x0603 + // At least something... this may leak undo entries + clear_history (); +#endif // RL_READLINE_VERSION < 0x0603 + + input_rl__restore_buffer (self, buffer); + self->current = buffer; +} + +static void +input_rl__buffer_destroy_wo_history (struct input_rl_buffer *self) +{ + free (self->history); + free (self->saved_line); + free (self); +} + +static void +input_rl_buffer_destroy (void *input, input_buffer_t input_buffer) +{ + (void) input; + struct input_rl_buffer *buffer = input_buffer; + + // rl_clear_history, being the only way I know of to get rid of the complete + // history including attached data, is a pretty recent addition. *sigh* +#if RL_READLINE_VERSION >= 0x0603 + if (buffer->history) + { + // See input_rl_buffer_switch() for why we need to do this BS + rl_free_undo_list (); + + // This is probably the only way we can free the history fully + HISTORY_STATE *state = history_get_history_state (); + + history_set_history_state (buffer->history); + rl_clear_history (); + // rl_clear_history just removes history entries, + // we have to reclaim memory for their actual container ourselves + free (buffer->history->entries); + free (buffer->history); + buffer->history = NULL; + + history_set_history_state (state); + free (state); + } +#endif // RL_READLINE_VERSION + + input_rl__buffer_destroy_wo_history (buffer); +} + +static input_buffer_t +input_rl_buffer_new (void *input) +{ + (void) input; + struct input_rl_buffer *self = xcalloc (1, sizeof *self); + return self; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Since {save,restore}_buffer() store history, we can't use them here like we +// do with libedit, because then buffer_destroy() can free memory that's still +// being used by readline. This situation is bound to happen on quit. + +static void +input_rl__save (struct input_rl *self) +{ + hard_assert (!self->saved_line); + + self->saved_point = rl_point; + self->saved_mark = rl_mark; + self->saved_line = rl_copy_text (0, rl_end); +} + +static void +input_rl__restore (struct input_rl *self) +{ + hard_assert (self->saved_line); + + rl_set_prompt (self->prompt); + rl_replace_line (self->saved_line, false); + rl_point = self->saved_point; + rl_mark = self->saved_mark; + cstr_set (&self->saved_line, NULL); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_rl_hide (void *input) +{ + struct input_rl *self = input; + if (!self->active || self->prompt_shown-- < 1) + return; + + input_rl__save (self); + input_rl__erase (self); +} + +static void +input_rl_show (void *input) +{ + struct input_rl *self = input; + if (!self->active || ++self->prompt_shown < 1) + return; + + input_rl__restore (self); + rl_redisplay (); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_rl_on_tty_resized (void *input) +{ + (void) input; + // This fucks up big time on terminals with automatic wrapping such as + // rxvt-unicode or newer VTE when the current line overflows, however we + // can't do much about that + rl_resize_terminal (); +} + +static void +input_rl_on_tty_readable (void *input) +{ + (void) input; + rl_callback_read_char (); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_rl_destroy (void *input) +{ + struct input_rl *self = input; + free (self->saved_line); + LIST_FOR_EACH (struct input_rl_fn, iter, self->fns) + ffi_closure_free (iter); + free (self->prompt); + free (self); +} + +#define XX(a) .a = input_rl_ ## a, +static struct input_vtable input_rl_vtable = { INPUT_VTABLE (XX) }; +#undef XX + +static struct input * +input_rl_new (void) +{ + struct input_rl *self = xcalloc (1, sizeof *self); + self->super.vtable = &input_rl_vtable; + return &self->super; +} + +#define input_new input_rl_new +#endif // HAVE_READLINE + +// ~~~ BSD Editline ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#ifdef HAVE_EDITLINE + +#include <histedit.h> + +#define INPUT_START_IGNORE '\x01' +#define INPUT_END_IGNORE '\x01' + +struct input_el_fn +{ + ffi_closure closure; ///< Closure + + LIST_HEADER (struct input_el_fn) + input_fn callback; ///< Real callback + void *user_data; ///< Real callback user data + + wchar_t *name; ///< Function name + wchar_t *help; ///< Function help +}; + +struct input_el_buffer +{ + HistoryW *history; ///< The history object + wchar_t *saved_line; ///< Saved line content + int saved_len; ///< Length of the saved line + int saved_point; ///< Saved cursor position +}; + +struct input_el +{ + struct input super; ///< Parent class + EditLine *editline; ///< The EditLine object + + bool active; ///< Are we a thing? + char *prompt; ///< The prompt we use + int prompt_shown; ///< Whether the prompt is shown now + + struct input_el_fn *fns; ///< Named functions + struct input_el_buffer *current; ///< Current input buffer +}; + +static void app_editline_init (struct input_el *self); + +static void +input_el__redisplay (void *input) +{ + // See rl_redisplay(), however NetBSD editline's map.c v1.54 breaks VREPRINT + // so we bind redisplay somewhere else in app_editline_init() + struct input_el *self = input; + wchar_t x[] = { L'q' & 31, 0 }; + el_wpush (self->editline, x); + + // We have to do this or it gets stuck and nothing is done + int dummy_count = 0; + (void) el_wgets (self->editline, &dummy_count); +} + +static char * +input_el__make_prompt (EditLine *editline) +{ + struct input_el *self = NULL; + el_get (editline, EL_CLIENTDATA, &self); + if (!self->prompt) + return ""; + return self->prompt; +} + +static char * +input_el__make_empty_prompt (EditLine *editline) +{ + (void) editline; + return ""; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_el_ding (void *input) +{ + // XXX: this isn't probably very portable; + // we could use "bell" from terminfo but that creates a dependency + (void) input; + write (STDOUT_FILENO, "\a", 1); +} + +static const char * +input_el_get_prompt (void *input) +{ + struct input_el *self = input; + return self->prompt; +} + +static void +input_el_set_prompt (void *input, char *prompt) +{ + struct input_el *self = input; + cstr_set (&self->prompt, prompt); + + if (self->prompt_shown > 0) + input_el__redisplay (self); +} + +static void +input_el_clear_line (void *input) +{ + struct input_el *self = input; + const LineInfoW *info = el_wline (self->editline); + int len = info->lastchar - info->buffer; + int point = info->cursor - info->buffer; + el_cursor (self->editline, len - point); + el_wdeletestr (self->editline, len); + input_el__redisplay (self); +} + +static void +input_el__erase (struct input_el *self) +{ + el_set (self->editline, EL_PROMPT, input_el__make_empty_prompt); + input_el_clear_line (self); +} + +static bool +input_el_insert (void *input, const char *s) +{ + struct input_el *self = input; + bool success = !*s || !el_insertstr (self->editline, s); + if (self->prompt_shown > 0) + input_el__redisplay (self); + return success; +} + +static char * +input_el_get_line (void *input, int *position) +{ + struct input_el *self = input; + const LineInfo *info = el_line (self->editline); + int point = info->cursor - info->buffer; + if (position) *position = point; + return xstrndup (info->buffer, info->lastchar - info->buffer); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_el_bind (void *input, const char *seq, const char *function_name) +{ + struct input_el *self = input; + el_set (self->editline, EL_BIND, seq, function_name, NULL); +} + +static void +input_el_bind_meta (void *input, char key, const char *function_name) +{ + char keyseq[] = { 'M', '-', key, 0 }; + input_el_bind (input, keyseq, function_name); +} + +static void +input_el_bind_control (void *input, char key, const char *function_name) +{ + char keyseq[] = { '^', key, 0 }; + input_el_bind (input, keyseq, function_name); +} + +static void +input_el__forward (ffi_cif *cif, void *ret, void **args, void *user_data) +{ + (void) cif; + + struct input_el_fn *data = user_data; + *(unsigned char *) ret = data->callback + (1, *(int *) args[1], data->user_data) ? CC_NORM : CC_ERROR; +} + +static wchar_t * +ascii_to_wide (const char *ascii) +{ + size_t len = strlen (ascii) + 1; + wchar_t *wide = xcalloc (sizeof *wide, len); + while (len--) + hard_assert ((wide[len] = (unsigned char) ascii[len]) < 0x80); + return wide; +} + +static void +input_el_register_fn (void *input, + const char *name, const char *help, input_fn callback, void *user_data) +{ + void *bound_fn = NULL; + struct input_el_fn *data = ffi_closure_alloc (sizeof *data, &bound_fn); + hard_assert (data); + + static ffi_cif cif; + static ffi_type *args[2] = { &ffi_type_pointer, &ffi_type_sint }; + hard_assert (ffi_prep_cif + (&cif, FFI_DEFAULT_ABI, 2, &ffi_type_uchar, args) == FFI_OK); + + data->user_data = user_data; + data->callback = callback; + data->name = ascii_to_wide (name); + data->help = ascii_to_wide (help); + hard_assert (ffi_prep_closure_loc (&data->closure, + &cif, input_el__forward, data, bound_fn) == FFI_OK); + + struct input_el *self = input; + el_wset (self->editline, EL_ADDFN, data->name, data->help, bound_fn); + LIST_PREPEND (self->fns, data); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_el_start (void *input, const char *program_name) +{ + struct input_el *self = input; + self->editline = el_init (program_name, stdin, stdout, stderr); + el_set (self->editline, EL_CLIENTDATA, self); + el_set (self->editline, EL_PROMPT_ESC, + input_el__make_prompt, INPUT_START_IGNORE); + el_set (self->editline, EL_SIGNAL, false); + el_set (self->editline, EL_UNBUFFERED, true); + el_set (self->editline, EL_EDITOR, "emacs"); + + app_editline_init (self); + + self->prompt_shown = 1; + self->active = true; +} + +static void +input_el_stop (void *input) +{ + struct input_el *self = input; + if (self->prompt_shown > 0) + input_el__erase (self); + + el_end (self->editline); + self->editline = NULL; + self->active = false; + self->prompt_shown = false; +} + +static void +input_el_prepare (void *input, bool enabled) +{ + struct input_el *self = input; + el_set (self->editline, EL_PREP_TERM, enabled); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_el__save_buffer (struct input_el *self, struct input_el_buffer *buffer) +{ + const LineInfoW *info = el_wline (self->editline); + int len = info->lastchar - info->buffer; + int point = info->cursor - info->buffer; + + wchar_t *line = calloc (sizeof *info->buffer, len + 1); + memcpy (line, info->buffer, sizeof *info->buffer * len); + el_cursor (self->editline, len - point); + el_wdeletestr (self->editline, len); + + buffer->saved_line = line; + buffer->saved_point = point; + buffer->saved_len = len; +} + +static void +input_el__save (struct input_el *self) +{ + if (self->current) + input_el__save_buffer (self, self->current); +} + +static void +input_el__restore_buffer (struct input_el *self, struct input_el_buffer *buffer) +{ + if (buffer->saved_line) + { + el_winsertstr (self->editline, buffer->saved_line); + el_cursor (self->editline, + -(buffer->saved_len - buffer->saved_point)); + free (buffer->saved_line); + buffer->saved_line = NULL; + } +} + +static void +input_el__restore (struct input_el *self) +{ + if (self->current) + input_el__restore_buffer (self, self->current); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Editline keeping its own history position (look for "eventno" there). +// This is the only sane way of resetting it. +static void +input_el__start_over (struct input_el *self) +{ + wchar_t x[] = { L'c' & 31, 0 }; + el_wpush (self->editline, x); + + int dummy_count = 0; + (void) el_wgets (self->editline, &dummy_count); +} + +static void +input_el_buffer_switch (void *input, input_buffer_t input_buffer) +{ + struct input_el *self = input; + struct input_el_buffer *buffer = input_buffer; + if (!self->active) + return; + + if (self->current) + input_el__save_buffer (self, self->current); + + self->current = buffer; + el_wset (self->editline, EL_HIST, history, buffer->history); + input_el__start_over (self); + input_el__restore_buffer (self, buffer); +} + +static void +input_el_buffer_destroy (void *input, input_buffer_t input_buffer) +{ + struct input_el *self = input; + struct input_el_buffer *buffer = input_buffer; + if (self->active && self->current == buffer) + { + el_wset (self->editline, EL_HIST, history, NULL); + self->current = NULL; + } + + history_wend (buffer->history); + free (buffer->saved_line); + free (buffer); +} + +static input_buffer_t +input_el_buffer_new (void *input) +{ + (void) input; + struct input_el_buffer *self = xcalloc (1, sizeof *self); + self->history = history_winit (); + + HistEventW ev; + history_w (self->history, &ev, H_SETSIZE, HISTORY_LIMIT); + return self; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_el_hide (void *input) +{ + struct input_el *self = input; + if (!self->active || self->prompt_shown-- < 1) + return; + + input_el__save (self); + input_el__erase (self); +} + +static void +input_el_show (void *input) +{ + struct input_el *self = input; + if (!self->active || ++self->prompt_shown < 1) + return; + + input_el__restore (self); + el_set (self->editline, + EL_PROMPT_ESC, input_el__make_prompt, INPUT_START_IGNORE); + input_el__redisplay (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_el_on_tty_resized (void *input) +{ + struct input_el *self = input; + el_resize (self->editline); +} + +static void +input_el_on_tty_readable (void *input) +{ + // We bind the return key to process it how we need to + struct input_el *self = input; + + // el_gets() with EL_UNBUFFERED doesn't work with UTF-8, + // we must use the wide-character interface + int count = 0; + const wchar_t *buf = el_wgets (self->editline, &count); + if (!buf || count-- <= 0) + return; + + if (count == 0 && buf[0] == ('D' - 0x40) /* hardcoded VEOF in el_wgets() */) + { + el_deletestr (self->editline, 1); + input_el__redisplay (self); + input_el_ding (self); + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +input_el_destroy (void *input) +{ + struct input_el *self = input; + LIST_FOR_EACH (struct input_el_fn, iter, self->fns) + { + free (iter->name); + free (iter->help); + ffi_closure_free (iter); + } + free (self->prompt); + free (self); +} + +#define XX(a) .a = input_el_ ## a, +static struct input_vtable input_el_vtable = { INPUT_VTABLE (XX) }; +#undef XX + +static struct input * +input_el_new (void) +{ + struct input_el *self = xcalloc (1, sizeof *self); + self->super.vtable = &input_el_vtable; + return &self->super; +} + +#define input_new input_el_new +#endif // HAVE_EDITLINE + +// --- Application data -------------------------------------------------------- + +// All text stored in our data structures is encoded in UTF-8. Or at least +// should be--our only ways of retrieving strings are: via the command line +// (converted from locale, no room for errors), via the configuration file +// (restrictive ASCII grammar for bare words and an internal check for strings), +// and via plugins (meticulously validated). +// +// The only exception is IRC identifiers. + +// ~~~ Scripting support ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +// We need a few reference countable objects with support for both strong +// and weak references (mainly used for scripted plugins). +// +// Beware that if you don't own the object, you will most probably want +// to keep the weak reference link so that you can get rid of it later. +// Also note that you have to make sure the user_data don't leak resources. +// +// Having a callback is more versatile than just nulling out a pointer. + +/// Callback just before a reference counted object is destroyed +typedef void (*destroy_cb_fn) (void *object, void *user_data); + +struct weak_ref_link +{ + LIST_HEADER (struct weak_ref_link) + + destroy_cb_fn on_destroy; ///< Called when object is destroyed + void *user_data; ///< User data +}; + +static struct weak_ref_link * +weak_ref (struct weak_ref_link **list, destroy_cb_fn cb, void *user_data) +{ + struct weak_ref_link *link = xcalloc (1, sizeof *link); + link->on_destroy = cb; + link->user_data = user_data; + LIST_PREPEND (*list, link); + return link; +} + +static void +weak_unref (struct weak_ref_link **list, struct weak_ref_link **link) +{ + if (*link) + LIST_UNLINK (*list, *link); + free (*link); + *link = NULL; +} + +#define REF_COUNTABLE_HEADER \ + size_t ref_count; /**< Reference count */ \ + struct weak_ref_link *weak_refs; /**< To remove any weak references */ + +#define REF_COUNTABLE_METHODS(name) \ + static struct name * \ + name ## _ref (struct name *self) \ + { \ + self->ref_count++; \ + return self; \ + } \ + \ + static void \ + name ## _unref (struct name *self) \ + { \ + if (--self->ref_count) \ + return; \ + LIST_FOR_EACH (struct weak_ref_link, iter, self->weak_refs) \ + { \ + iter->on_destroy (self, iter->user_data); \ + free (iter); \ + } \ + name ## _destroy (self); \ + } \ + \ + static struct weak_ref_link * \ + name ## _weak_ref (struct name *self, destroy_cb_fn cb, void *user_data) \ + { return weak_ref (&self->weak_refs, cb, user_data); } \ + \ + static void \ + name ## _weak_unref (struct name *self, struct weak_ref_link **link) \ + { weak_unref (&self->weak_refs, link); } + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Simple introspection framework to simplify exporting stuff to Lua, since +// there is a lot of it. While not fully automated, at least we have control +// over which fields are exported. + +enum ispect_type +{ + ISPECT_BOOL, ISPECT_INT, ISPECT_UINT, ISPECT_SIZE, ISPECT_STRING, + + ISPECT_STR, ///< "struct str" + ISPECT_STR_MAP, ///< "struct str_map" + ISPECT_REF ///< Weakly referenced object +}; + +struct ispect_field +{ + const char *name; ///< Name of the field + size_t offset; ///< Offset in the structure + enum ispect_type type; ///< Type of the field + + enum ispect_type subtype; ///< STR_MAP subtype + struct ispect_field *fields; ///< REF target fields + bool is_list; ///< REF target is a list +}; + +#define ISPECT(object, field, type) \ + { #field, offsetof (struct object, field), ISPECT_##type, 0, NULL, false }, +#define ISPECT_REF(object, field, is_list, ref_type) \ + { #field, offsetof (struct object, field), ISPECT_REF, 0, \ + g_##ref_type##_ispect, is_list }, +#define ISPECT_MAP(object, field, subtype) \ + { #field, offsetof (struct object, field), ISPECT_STR_MAP, \ + ISPECT_##subtype, NULL, false }, +#define ISPECT_MAP_REF(object, field, is_list, ref_type) \ + { #field, offsetof (struct object, field), ISPECT_STR_MAP, \ + ISPECT_REF, g_##ref_type##_ispect, is_list }, + +// ~~~ Chat ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +struct user_channel +{ + LIST_HEADER (struct user_channel) + + struct channel *channel; ///< Reference to channel +}; + +static struct user_channel * +user_channel_new (struct channel *channel) +{ + struct user_channel *self = xcalloc (1, sizeof *self); + self->channel = channel; + return self; +} + +static void +user_channel_destroy (struct user_channel *self) +{ + // The "channel" reference is weak and this object should get + // destroyed whenever the user stops being in the channel. + free (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// We keep references to user information in channels and buffers, +// and weak references in the name lookup table. + +struct user +{ + REF_COUNTABLE_HEADER + + char *nickname; ///< Literal nickname + bool away; ///< User is away + + struct user_channel *channels; ///< Channels the user is on (with us) +}; + +static struct ispect_field g_user_ispect[] = +{ + ISPECT( user, nickname, STRING ) + ISPECT( user, away, BOOL ) + {} +}; + +static struct user * +user_new (char *nickname) +{ + struct user *self = xcalloc (1, sizeof *self); + self->ref_count = 1; + self->nickname = nickname; + return self; +} + +static void +user_destroy (struct user *self) +{ + free (self->nickname); + LIST_FOR_EACH (struct user_channel, iter, self->channels) + user_channel_destroy (iter); + free (self); +} + +REF_COUNTABLE_METHODS (user) + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct channel_user +{ + LIST_HEADER (struct channel_user) + + struct user *user; ///< Reference to user + char *prefixes; ///< Ordered @+... characters +}; + +static struct channel_user * +channel_user_new (struct user *user, const char *prefixes) +{ + struct channel_user *self = xcalloc (1, sizeof *self); + self->user = user; + self->prefixes = xstrdup (prefixes); + return self; +} + +static void +channel_user_destroy (struct channel_user *self) +{ + user_unref (self->user); + free (self->prefixes); + free (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// We keep references to channels in their buffers, +// and weak references in their users and the name lookup table. + +struct channel +{ + REF_COUNTABLE_HEADER + + struct server *s; ///< Server + + char *name; ///< Channel name + char *topic; ///< Channel topic + + // XXX: write something like an ordered set of characters object? + struct str no_param_modes; ///< No parameter channel modes + struct str_map param_modes; ///< Parametrized channel modes + + struct channel_user *users; ///< Channel users + struct strv names_buf; ///< Buffer for RPL_NAMREPLY + size_t users_len; ///< User count + + bool left_manually; ///< Don't rejoin on reconnect + bool show_names_after_who; ///< RPL_ENDOFWHO delays RPL_ENDOFNAMES +}; + +static struct ispect_field g_channel_ispect[] = +{ + ISPECT( channel, name, STRING ) + ISPECT( channel, topic, STRING ) + ISPECT( channel, no_param_modes, STR ) + ISPECT_MAP( channel, param_modes, STRING ) + ISPECT( channel, users_len, SIZE ) + ISPECT( channel, left_manually, BOOL ) + {} +}; + +static struct channel * +channel_new (struct server *s, char *name) +{ + struct channel *self = xcalloc (1, sizeof *self); + self->ref_count = 1; + self->s = s; + self->name = name; + self->no_param_modes = str_make (); + self->param_modes = str_map_make (free); + self->names_buf = strv_make (); + return self; +} + +static void +channel_destroy (struct channel *self) +{ + free (self->name); + free (self->topic); + str_free (&self->no_param_modes); + str_map_free (&self->param_modes); + // Owner has to make sure we have no users by now + hard_assert (!self->users && !self->users_len); + strv_free (&self->names_buf); + free (self); +} + +REF_COUNTABLE_METHODS (channel) + +// ~~~ Attribute utilities ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +enum +{ + TEXT_BOLD = 1 << 0, + TEXT_ITALIC = 1 << 1, + TEXT_UNDERLINE = 1 << 2, + TEXT_INVERSE = 1 << 3, + TEXT_BLINK = 1 << 4, + TEXT_CROSSED_OUT = 1 << 5, + TEXT_MONOSPACE = 1 << 6 +}; + +// Similar to code in liberty-tui.c. +struct attrs +{ + short fg; ///< Foreground (256-colour cube or -1) + short bg; ///< Background (256-colour cube or -1) + unsigned attrs; ///< TEXT_* mask +}; + +/// Decode attributes in the value using a subset of the git config format, +/// ignoring all errors since it doesn't affect functionality +static struct attrs +attrs_decode (const char *value) +{ + struct strv v = strv_make (); + cstr_split (value, " ", true, &v); + + int colors = 0; + struct attrs attrs = { -1, -1, 0 }; + for (char **it = v.vector; *it; it++) + { + char *end = NULL; + long n = strtol (*it, &end, 10); + if (*it != end && !*end && n >= SHRT_MIN && n <= SHRT_MAX) + { + if (colors == 0) attrs.fg = n; + if (colors == 1) attrs.bg = n; + colors++; + } + else if (!strcmp (*it, "bold")) attrs.attrs |= TEXT_BOLD; + else if (!strcmp (*it, "italic")) attrs.attrs |= TEXT_ITALIC; + else if (!strcmp (*it, "ul")) attrs.attrs |= TEXT_UNDERLINE; + else if (!strcmp (*it, "reverse")) attrs.attrs |= TEXT_INVERSE; + else if (!strcmp (*it, "blink")) attrs.attrs |= TEXT_BLINK; + else if (!strcmp (*it, "strike")) attrs.attrs |= TEXT_CROSSED_OUT; + } + strv_free (&v); + return attrs; +} + +// ~~~ Buffers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +enum formatter_item_type +{ + FORMATTER_ITEM_END, ///< Sentinel value for arrays + FORMATTER_ITEM_TEXT, ///< Text + FORMATTER_ITEM_ATTR, ///< Formatting attributes + FORMATTER_ITEM_FG_COLOR, ///< Foreground colour + FORMATTER_ITEM_BG_COLOR, ///< Background colour + FORMATTER_ITEM_SIMPLE, ///< Toggle IRC formatting + FORMATTER_ITEM_IGNORE_ATTR ///< Un/set attribute ignoration +}; + +struct formatter_item +{ + enum formatter_item_type type : 16; ///< Type of this item + int attribute : 16; ///< Attribute ID or a TEXT_* mask + int color; ///< Colour ([256 << 16] | 16) + char *text; ///< String +}; + +static void +formatter_item_free (struct formatter_item *self) +{ + free (self->text); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct formatter +{ + struct app_context *ctx; ///< Application context + struct server *s; ///< Server + + bool clean; ///< Assume ATTR_RESET + struct formatter_item *items; ///< Items + size_t items_len; ///< Items used + size_t items_alloc; ///< Items allocated +}; + +static struct formatter +formatter_make (struct app_context *ctx, struct server *s) +{ + struct formatter self = { .ctx = ctx, .s = s, .clean = true }; + self.items = xcalloc (sizeof *self.items, (self.items_alloc = 16)); + return self; +} + +static void +formatter_free (struct formatter *self) +{ + for (size_t i = 0; i < self->items_len; i++) + formatter_item_free (&self->items[i]); + free (self->items); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +enum buffer_line_flags +{ + BUFFER_LINE_SKIP_FILE = 1 << 0, ///< Don't log this to file + BUFFER_LINE_UNIMPORTANT = 1 << 1, ///< Joins, parts, similar spam + BUFFER_LINE_HIGHLIGHT = 1 << 2, ///< The user was highlighted by this +}; + +// NOTE: This sequence must match up with xC-proto, only one lower. +enum buffer_line_rendition +{ + BUFFER_LINE_BARE, ///< Unadorned + BUFFER_LINE_INDENT, ///< Just indent the line + BUFFER_LINE_STATUS, ///< Status message + BUFFER_LINE_ERROR, ///< Error message + BUFFER_LINE_JOIN, ///< Join arrow + BUFFER_LINE_PART, ///< Part arrow + BUFFER_LINE_ACTION, ///< Highlighted asterisk +}; + +struct buffer_line +{ + LIST_HEADER (struct buffer_line) + + unsigned flags; ///< Functional flags + enum buffer_line_rendition r; ///< What the line should look like + time_t when; ///< Time of the event + struct formatter_item items[]; ///< Line data +}; + +/// Create a new buffer line stealing all data from the provided formatter +struct buffer_line * +buffer_line_new (struct formatter *f) +{ + // We make space for one more item that gets initialized to all zeros, + // meaning FORMATTER_ITEM_END (because it's the first value in the enum) + size_t items_size = f->items_len * sizeof *f->items; + struct buffer_line *self = + xcalloc (1, sizeof *self + items_size + sizeof *self->items); + memcpy (self->items, f->items, items_size); + + // We've stolen pointers from the formatter, let's destroy it altogether + free (f->items); + memset (f, 0, sizeof *f); + return self; +} + +static void +buffer_line_destroy (struct buffer_line *self) +{ + for (struct formatter_item *iter = self->items; iter->type; iter++) + formatter_item_free (iter); + free (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +enum buffer_type +{ + BUFFER_GLOBAL, ///< Global information + BUFFER_SERVER, ///< Server-related messages + BUFFER_CHANNEL, ///< Channels + BUFFER_PM ///< Private messages (query) +}; + +struct buffer +{ + LIST_HEADER (struct buffer) + REF_COUNTABLE_HEADER + + enum buffer_type type; ///< Type of the buffer + char *name; ///< The name of the buffer + + struct input *input; ///< API for "input_data" + input_buffer_t input_data; ///< User interface data + + // Buffer contents: + + struct buffer_line *lines; ///< All lines in this buffer + struct buffer_line *lines_tail; ///< The tail of buffer lines + unsigned lines_count; ///< How many lines we have + + unsigned new_messages_count; ///< # messages since last left + unsigned new_unimportant_count; ///< How much of that is unimportant + bool highlighted; ///< We've been highlighted + bool hide_unimportant; ///< Hide unimportant messages + + FILE *log_file; ///< Log file + + // Origin information: + + struct server *server; ///< Reference to server + struct channel *channel; ///< Reference to channel + struct user *user; ///< Reference to user +}; + +static struct ispect_field g_server_ispect[]; +static struct ispect_field g_buffer_ispect[] = +{ + ISPECT( buffer, name, STRING ) + ISPECT( buffer, new_messages_count, UINT ) + ISPECT( buffer, new_unimportant_count, UINT ) + ISPECT( buffer, highlighted, BOOL ) + ISPECT( buffer, hide_unimportant, BOOL ) + ISPECT_REF( buffer, server, false, server ) + ISPECT_REF( buffer, channel, false, channel ) + ISPECT_REF( buffer, user, false, user ) + {} +}; + +static struct buffer * +buffer_new (struct input *input, enum buffer_type type, char *name) +{ + struct buffer *self = xcalloc (1, sizeof *self); + self->ref_count = 1; + self->input = input; + self->input_data = CALL (input, buffer_new); + self->type = type; + self->name = name; + return self; +} + +static void +buffer_destroy (struct buffer *self) +{ + free (self->name); + if (self->input_data) + { +#ifdef HAVE_READLINE + // FIXME: can't really free "history" contents from here, as we cannot + // be sure that the user interface pointer is valid and usable + input_rl__buffer_destroy_wo_history (self->input_data); +#else // ! HAVE_READLINE + CALL_ (self->input, buffer_destroy, self->input_data); +#endif // ! HAVE_READLINE + } + LIST_FOR_EACH (struct buffer_line, iter, self->lines) + buffer_line_destroy (iter); + if (self->log_file) + (void) fclose (self->log_file); + if (self->user) + user_unref (self->user); + if (self->channel) + channel_unref (self->channel); + free (self); +} + +REF_COUNTABLE_METHODS (buffer) +#define buffer_ref do_not_use_dangerous + +// ~~~ Relay ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +struct client +{ + LIST_HEADER (struct client) + struct app_context *ctx; ///< Application context + + // TODO: Convert this all to TLS, and only TLS, with required client cert. + // That means replacing plumbing functions with the /other/ set from xD. + + int socket_fd; ///< The TCP socket + struct str read_buffer; ///< Unprocessed input + struct str write_buffer; ///< Output yet to be sent out + + uint32_t event_seq; ///< Outgoing message counter + bool initialized; ///< Initial sync took place + + struct poller_fd socket_event; ///< The socket can be read/written to +}; + +static struct client * +client_new (void) +{ + struct client *self = xcalloc (1, sizeof *self); + self->socket_fd = -1; + self->read_buffer = str_make (); + self->write_buffer = str_make (); + return self; +} + +static void +client_destroy (struct client *self) +{ + if (!soft_assert (self->socket_fd == -1)) + xclose (self->socket_fd); + + str_free (&self->read_buffer); + str_free (&self->write_buffer); + free (self); +} + +static void client_kill (struct client *c); + +// ~~~ Server ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +// The only real purpose of this is to abstract away TLS +struct transport +{ + /// Initialize the transport + bool (*init) (struct server *s, const char *hostname, struct error **e); + /// Destroy the user data pointer + void (*cleanup) (struct server *s); + + /// The underlying socket may have become readable, update `read_buffer' + enum socket_io_result (*try_read) (struct server *s); + /// The underlying socket may have become writeable, flush `write_buffer' + enum socket_io_result (*try_write) (struct server *s); + /// Return event mask to use in the poller + int (*get_poll_events) (struct server *s); + + /// Called just before closing the connection from our side + void (*in_before_shutdown) (struct server *s); +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +enum server_state +{ + IRC_DISCONNECTED, ///< Not connected + IRC_CONNECTING, ///< Connecting to the server + IRC_CONNECTED, ///< Trying to register + IRC_REGISTERED, ///< We can chat now + IRC_CLOSING, ///< Flushing output before shutdown + IRC_HALF_CLOSED ///< Connection shutdown from our side +}; + +/// Convert an IRC identifier character to lower-case +typedef int (*irc_tolower_fn) (int); + +/// Key conversion function for hashmap lookups +typedef size_t (*irc_strxfrm_fn) (char *, const char *, size_t); + +struct server +{ + REF_COUNTABLE_HEADER + struct app_context *ctx; ///< Application context + + char *name; ///< Server identifier + struct buffer *buffer; ///< The buffer for this server + struct config_item *config; ///< Configuration root + + // Connection: + + enum server_state state; ///< Connection state + struct connector *connector; ///< Connection establisher + struct socks_connector *socks_conn; ///< SOCKS connection establisher + unsigned reconnect_attempt; ///< Number of reconnect attempt + bool manual_disconnect; ///< Don't reconnect after disconnect + + int socket; ///< Socket FD of the server + struct str read_buffer; ///< Input yet to be processed + struct str write_buffer; ///< Outut yet to be be sent out + struct poller_fd socket_event; ///< We can read from the socket + + struct transport *transport; ///< Transport method + void *transport_data; ///< Transport data + + // Events: + + struct poller_timer ping_tmr; ///< We should send a ping + struct poller_timer timeout_tmr; ///< Connection seems to be dead + struct poller_timer reconnect_tmr; ///< We should reconnect now + struct poller_timer autojoin_tmr; ///< Re/join channels as appropriate + + // IRC: + + // TODO: an output queue to prevent excess floods (this will be needed + // especially for away status polling) + + bool rehashing; ///< Rehashing IRC identifiers + + struct str_map irc_users; ///< IRC user data + struct str_map irc_channels; ///< IRC channel data + struct str_map irc_buffer_map; ///< Maps IRC identifiers to buffers + + struct user *irc_user; ///< Our own user + int nick_counter; ///< Iterates "nicks" when registering + struct str irc_user_modes; ///< Our current user modes + char *irc_user_host; ///< Our current user@host + bool autoaway_active; ///< Autoaway is currently active + + struct strv outstanding_joins; ///< JOINs we expect a response to + + struct strv cap_ls_buf; ///< Buffer for IRCv3.2 CAP LS + bool cap_echo_message; ///< Whether the server echoes messages + bool cap_away_notify; ///< Whether we get AWAY notifications + bool cap_sasl; ///< Whether SASL is available + + // Server-specific information (from RPL_ISUPPORT): + + irc_tolower_fn irc_tolower; ///< Server tolower() + irc_strxfrm_fn irc_strxfrm; ///< Server strxfrm() + + char *irc_chantypes; ///< Channel types (name prefixes) + char *irc_idchan_prefixes; ///< Prefixes for "safe channels" + char *irc_statusmsg; ///< Prefixes for channel targets + + char irc_extban_prefix; ///< EXTBAN prefix or \0 + char *irc_extban_types; ///< EXTBAN types + + char *irc_chanmodes_list; ///< Channel modes for lists + char *irc_chanmodes_param_always; ///< Channel modes with mandatory param + char *irc_chanmodes_param_when_set; ///< Channel modes with param when set + char *irc_chanmodes_param_never; ///< Channel modes without param + + char *irc_chanuser_prefixes; ///< Channel user prefixes + char *irc_chanuser_modes; ///< Channel user modes + + unsigned irc_max_modes; ///< Max parametrized modes per command +}; + +static struct ispect_field g_server_ispect[] = +{ + ISPECT( server, name, STRING ) + ISPECT( server, state, INT ) + ISPECT( server, reconnect_attempt, UINT ) + ISPECT( server, manual_disconnect, BOOL ) + ISPECT( server, irc_user_host, STRING ) + ISPECT( server, autoaway_active, BOOL ) + ISPECT( server, cap_echo_message, BOOL ) + ISPECT_REF( server, buffer, false, buffer ) + + // TODO: either rename the underlying field or fix the plugins + { "user", offsetof (struct server, irc_user), + ISPECT_REF, 0, g_user_ispect, false }, + { "user_mode", offsetof (struct server, irc_user_modes), + ISPECT_STR, 0, NULL, false }, + + {} +}; + +static void on_irc_timeout (void *user_data); +static void on_irc_ping_timeout (void *user_data); +static void on_irc_autojoin_timeout (void *user_data); +static void irc_initiate_connect (struct server *s); + +static void +server_init_specifics (struct server *self) +{ + // Defaults as per the RPL_ISUPPORT drafts, or RFC 1459 + + self->irc_tolower = irc_tolower; + self->irc_strxfrm = irc_strxfrm; + + self->irc_chantypes = xstrdup ("#&"); + self->irc_idchan_prefixes = xstrdup (""); + self->irc_statusmsg = xstrdup (""); + + self->irc_extban_prefix = 0; + self->irc_extban_types = xstrdup (""); + + self->irc_chanmodes_list = xstrdup ("b"); + self->irc_chanmodes_param_always = xstrdup ("k"); + self->irc_chanmodes_param_when_set = xstrdup ("l"); + self->irc_chanmodes_param_never = xstrdup ("imnpst"); + + self->irc_chanuser_prefixes = xstrdup ("@+"); + self->irc_chanuser_modes = xstrdup ("ov"); + + self->irc_max_modes = 3; +} + +static void +server_free_specifics (struct server *self) +{ + free (self->irc_chantypes); + free (self->irc_idchan_prefixes); + free (self->irc_statusmsg); + + free (self->irc_extban_types); + + free (self->irc_chanmodes_list); + free (self->irc_chanmodes_param_always); + free (self->irc_chanmodes_param_when_set); + free (self->irc_chanmodes_param_never); + + free (self->irc_chanuser_prefixes); + free (self->irc_chanuser_modes); +} + +static struct server * +server_new (struct poller *poller) +{ + struct server *self = xcalloc (1, sizeof *self); + self->ref_count = 1; + + self->socket = -1; + self->read_buffer = str_make (); + self->write_buffer = str_make (); + self->state = IRC_DISCONNECTED; + + self->timeout_tmr = poller_timer_make (poller); + self->timeout_tmr.dispatcher = on_irc_timeout; + self->timeout_tmr.user_data = self; + + self->ping_tmr = poller_timer_make (poller); + self->ping_tmr.dispatcher = on_irc_ping_timeout; + self->ping_tmr.user_data = self; + + self->reconnect_tmr = poller_timer_make (poller); + self->reconnect_tmr.dispatcher = (poller_timer_fn) irc_initiate_connect; + self->reconnect_tmr.user_data = self; + + self->autojoin_tmr = poller_timer_make (poller); + self->autojoin_tmr.dispatcher = on_irc_autojoin_timeout; + self->autojoin_tmr.user_data = self; + + self->irc_users = str_map_make (NULL); + self->irc_users.key_xfrm = irc_strxfrm; + self->irc_channels = str_map_make (NULL); + self->irc_channels.key_xfrm = irc_strxfrm; + self->irc_buffer_map = str_map_make (NULL); + self->irc_buffer_map.key_xfrm = irc_strxfrm; + + self->irc_user_modes = str_make (); + + self->outstanding_joins = strv_make (); + self->cap_ls_buf = strv_make (); + server_init_specifics (self); + return self; +} + +static void +server_destroy (struct server *self) +{ + free (self->name); + + if (self->connector) + { + connector_free (self->connector); + free (self->connector); + } + if (self->socks_conn) + { + socks_connector_free (self->socks_conn); + free (self->socks_conn); + } + + if (self->transport + && self->transport->cleanup) + self->transport->cleanup (self); + + if (self->socket != -1) + { + poller_fd_reset (&self->socket_event); + xclose (self->socket); + } + str_free (&self->read_buffer); + str_free (&self->write_buffer); + + poller_timer_reset (&self->ping_tmr); + poller_timer_reset (&self->timeout_tmr); + poller_timer_reset (&self->reconnect_tmr); + poller_timer_reset (&self->autojoin_tmr); + + str_map_free (&self->irc_users); + str_map_free (&self->irc_channels); + str_map_free (&self->irc_buffer_map); + + if (self->irc_user) + user_unref (self->irc_user); + str_free (&self->irc_user_modes); + free (self->irc_user_host); + + strv_free (&self->outstanding_joins); + strv_free (&self->cap_ls_buf); + server_free_specifics (self); + free (self); +} + +REF_COUNTABLE_METHODS (server) +#define server_ref do_not_use_dangerous + +// ~~~ Scripting ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +struct plugin +{ + LIST_HEADER (struct plugin) + + char *name; ///< Name of the plugin + struct plugin_vtable *vtable; ///< Methods +}; + +struct plugin_vtable +{ + /// Collect garbage + void (*gc) (struct plugin *self); + /// Unregister and free the plugin including all relevant resources + void (*free) (struct plugin *self); +}; + +static void +plugin_destroy (struct plugin *self) +{ + self->vtable->free (self); + free (self->name); + free (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// This is a bit ugly since insertion is O(n) and the need to get rid of the +// specific type because of list macros, however I don't currently posses any +// strictly better, ordered data structure + +struct hook +{ + LIST_HEADER (struct hook) + int priority; ///< The lesser the sooner +}; + +static struct hook * +hook_insert (struct hook *list, struct hook *item) +{ + // Corner cases: list is empty or we precede everything + if (!list || item->priority < list->priority) + { + LIST_PREPEND (list, item); + return list; + } + + // Otherwise fast-forward to the last entry that precedes us + struct hook *before = list; + while (before->next && before->next->priority < item->priority) + before = before->next; + + // And link ourselves in between it and its successor + if ((item->next = before->next)) + item->next->prev = item; + before->next = item; + item->prev = before; + return list; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct input_hook +{ + struct hook super; ///< Common hook fields + + /// Takes over the ownership of "input", returns either NULL if input + /// was thrown away, or a possibly modified version of it + char *(*filter) (struct input_hook *self, + struct buffer *buffer, char *input); +}; + +struct irc_hook +{ + struct hook super; ///< Common hook fields + + /// Takes over the ownership of "message", returns either NULL if message + /// was thrown away, or a possibly modified version of it + char *(*filter) (struct irc_hook *self, + struct server *server, char *message); +}; + +struct prompt_hook +{ + struct hook super; ///< Common hook fields + + /// Returns what the prompt should look like right now based on other state + char *(*make) (struct prompt_hook *self); +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct completion_word +{ + size_t start; ///< Offset to start of word + size_t end; ///< Offset to end of word +}; + +struct completion +{ + char *line; ///< The line which is being completed + + struct completion_word *words; ///< Word locations + size_t words_len; ///< Number of words + size_t words_alloc; ///< Number of words allocated + + size_t location; ///< Which word is being completed +}; + +struct completion_hook +{ + struct hook super; ///< Common hook fields + + /// Tries to add possible completions of "word" to "output" + void (*complete) (struct completion_hook *self, + struct completion *data, const char *word, struct strv *output); +}; + +// ~~~ Main context ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +struct app_context +{ + /// Default terminal attributes + struct attrs theme_defaults[ATTR_COUNT]; + + // Configuration: + + struct config config; ///< Program configuration + struct attrs theme[ATTR_COUNT]; ///< Terminal attributes + bool isolate_buffers; ///< Isolate global/server buffers + bool beep_on_highlight; ///< Beep on highlight + bool logging; ///< Logging to file enabled + bool show_all_prefixes; ///< Show all prefixes before nicks + bool word_wrapping; ///< Enable simple word wrapping + + struct str_map servers; ///< Our servers + + // Relay: + + int relay_fd; ///< Listening socket FD + struct client *clients; ///< Our relay clients + + /// A single message buffer to prepare all outcoming messages within + struct relay_event_message relay_message; + + // Events: + + struct poller_fd tty_event; ///< Terminal input event + struct poller_fd signal_event; ///< Signal FD event + struct poller_fd relay_event; ///< New relay connection available + + struct poller_timer flush_timer; ///< Flush all open files (e.g. logs) + struct poller_timer date_chg_tmr; ///< Print a date change + struct poller_timer autoaway_tmr; ///< Autoaway timer + + struct poller poller; ///< Manages polled descriptors + bool quitting; ///< User requested quitting + bool polling; ///< The event loop is running + + // Buffers: + + struct buffer *buffers; ///< All our buffers in order + struct buffer *buffers_tail; ///< The tail of our buffers + + struct buffer *global_buffer; ///< The global buffer + struct buffer *current_buffer; ///< The current buffer + struct buffer *last_buffer; ///< Last used buffer + + struct str_map buffers_by_name; ///< Buffers by name + + unsigned backlog_limit; ///< Limit for buffer lines + time_t last_displayed_msg_time; ///< Time of last displayed message + + // Terminal: + + iconv_t term_to_utf8; ///< Terminal encoding to UTF-8 + iconv_t term_from_utf8; ///< UTF-8 to terminal encoding + + struct input *input; ///< User interface + + struct poller_idle prompt_event; ///< Deferred prompt refresh + struct poller_idle input_event; ///< Pending input event + struct strv pending_input; ///< Pending input lines + + int *nick_palette; ///< A 256-colour palette for nicknames + size_t nick_palette_len; ///< Number of entries in nick_palette + + bool awaiting_formatting_escape; ///< Awaiting an IRC formatting escape + bool in_bracketed_paste; ///< User is pasting some content + struct str input_buffer; ///< Buffered pasted content + + bool running_pager; ///< Running a pager for buffer history + bool running_editor; ///< Running editor for the input + char *editor_filename; ///< The file being edited by user + int terminal_suspended; ///< Terminal suspension level + + // Plugins: + + struct plugin *plugins; ///< Loaded plugins + struct hook *input_hooks; ///< Input hooks + struct hook *irc_hooks; ///< IRC hooks + struct hook *prompt_hooks; ///< Prompt hooks + struct hook *completion_hooks; ///< Autocomplete hooks +} +*g_ctx; + +static struct ispect_field g_ctx_ispect[] = +{ + ISPECT_MAP_REF( app_context, servers, false, server ) + ISPECT_REF( app_context, buffers, true, buffer ) + ISPECT_REF( app_context, global_buffer, false, buffer ) + ISPECT_REF( app_context, current_buffer, false, buffer ) + {} +}; + +static int * +filter_color_cube_for_acceptable_nick_colors (size_t *len) +{ + // This is a pure function and we don't use threads, static storage is fine + static int table[6 * 6 * 6]; + size_t len_counter = 0; + for (int x = 0; x < (int) N_ELEMENTS (table); x++) + { + int r = x / 36; + int g = (x / 6) % 6; + int b = (x % 6); + + // The first step is 95/255, the rest are 40/255, + // as an approximation we can double the first step + double linear_R = pow ((r + !!r) / 6., 2.2); + double linear_G = pow ((g + !!g) / 6., 2.2); + double linear_B = pow ((b + !!b) / 6., 2.2); + + // Use the relative luminance of colours within the cube to filter + // colours that look okay-ish on terminals with both black and white + // backgrounds (use the test-nick-colors script to calibrate) + double Y = 0.2126 * linear_R + 0.7152 * linear_G + 0.0722 * linear_B; + if (Y >= .25 && Y <= .4) + table[len_counter++] = 16 + x; + } + *len = len_counter; + return table; +} + +static bool +app_iconv_open (iconv_t *target, const char *to, const char *from) +{ + if (ICONV_ACCEPTS_TRANSLIT) + { + char *to_real = xstrdup_printf ("%s//TRANSLIT", to); + *target = iconv_open (to_real, from); + free (to_real); + } + else + *target = iconv_open (to, from); + return *target != (iconv_t) -1; +} + +static void +app_context_init (struct app_context *self) +{ + memset (self, 0, sizeof *self); + + self->config = config_make (); + poller_init (&self->poller); + + self->relay_fd = -1; + + self->servers = str_map_make ((str_map_free_fn) server_unref); + self->servers.key_xfrm = tolower_ascii_strxfrm; + + self->buffers_by_name = str_map_make (NULL); + self->buffers_by_name.key_xfrm = tolower_ascii_strxfrm; + + // So that we don't lose the logo shortly after startup + self->backlog_limit = 1000; + self->last_displayed_msg_time = time (NULL); + + char *native = nl_langinfo (CODESET); + if (!app_iconv_open (&self->term_from_utf8, native, "UTF-8") + || !app_iconv_open (&self->term_to_utf8, "UTF-8", native)) + exit_fatal ("creating the UTF-8 conversion object failed: %s", + strerror (errno)); + + self->input = input_new (); + self->input->user_data = self; + self->pending_input = strv_make (); + self->input_buffer = str_make (); + + self->nick_palette = + filter_color_cube_for_acceptable_nick_colors (&self->nick_palette_len); +} + +static void +app_context_relay_stop (struct app_context *self) +{ + if (self->relay_fd != -1) + { + poller_fd_reset (&self->relay_event); + xclose (self->relay_fd); + self->relay_fd = -1; + } +} + +static void +app_context_free (struct app_context *self) +{ + // Plugins can try to use of the other fields when destroyed + LIST_FOR_EACH (struct plugin, iter, self->plugins) + plugin_destroy (iter); + + config_free (&self->config); + + LIST_FOR_EACH (struct buffer, iter, self->buffers) + { +#ifdef HAVE_READLINE + // We can use the user interface here; see buffer_destroy() + CALL_ (self->input, buffer_destroy, iter->input_data); + iter->input_data = NULL; +#endif // HAVE_READLINE + buffer_unref (iter); + } + str_map_free (&self->buffers_by_name); + + app_context_relay_stop (self); + LIST_FOR_EACH (struct client, c, self->clients) + client_kill (c); + relay_event_message_free (&self->relay_message); + + str_map_free (&self->servers); + poller_free (&self->poller); + + iconv_close (self->term_from_utf8); + iconv_close (self->term_to_utf8); + + CALL (self->input, destroy); + strv_free (&self->pending_input); + str_free (&self->input_buffer); + + free (self->editor_filename); +} + +static void +refresh_prompt (struct app_context *ctx) +{ + // XXX: the need for this conditional could probably be resolved + // by some clever reordering + if (ctx->prompt_event.poller) + poller_idle_set (&ctx->prompt_event); +} + +// --- Configuration ----------------------------------------------------------- + +static void +on_config_debug_mode_change (struct config_item *item) +{ + g_debug_mode = item->value.boolean; +} + +static void +on_config_show_all_prefixes_change (struct config_item *item) +{ + struct app_context *ctx = item->user_data; + ctx->show_all_prefixes = item->value.boolean; + refresh_prompt (ctx); +} + +static void on_config_relay_bind_change (struct config_item *item); +static void on_config_backlog_limit_change (struct config_item *item); +static void on_config_theme_change (struct config_item *item); +static void on_config_logging_change (struct config_item *item); + +#define TRIVIAL_BOOLEAN_ON_CHANGE(name) \ + static void \ + on_config_ ## name ## _change (struct config_item *item) \ + { \ + struct app_context *ctx = item->user_data; \ + ctx->name = item->value.boolean; \ + } + +TRIVIAL_BOOLEAN_ON_CHANGE (isolate_buffers) +TRIVIAL_BOOLEAN_ON_CHANGE (beep_on_highlight) +TRIVIAL_BOOLEAN_ON_CHANGE (word_wrapping) + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +config_validate_nonjunk_string + (const struct config_item *item, struct error **e) +{ + if (item->type == CONFIG_ITEM_NULL) + return true; + + hard_assert (config_item_type_is_string (item->type)); + for (size_t i = 0; i < item->value.string.len; i++) + { + // Not even a tabulator + unsigned char c = item->value.string.str[i]; + if (iscntrl_ascii (c)) + { + error_set (e, "control characters are not allowed"); + return false; + } + } + return true; +} + +static bool +config_validate_addresses + (const struct config_item *item, struct error **e) +{ + if (item->type == CONFIG_ITEM_NULL) + return true; + if (!config_validate_nonjunk_string (item, e)) + return false; + + // Comma-separated list of "host[:port]" pairs + regex_t re; + int err = regcomp (&re, "^([^/:,]+(:[^/:,]+)?)?" + "(,([^/:,]+(:[^/:,]+)?)?)*$", REG_EXTENDED | REG_NOSUB); + hard_assert (!err); + + bool result = !regexec (&re, item->value.string.str, 0, NULL, 0); + if (!result) + error_set (e, "invalid address list string"); + + regfree (&re); + return result; +} + +static bool +config_validate_nonnegative + (const struct config_item *item, struct error **e) +{ + if (item->type == CONFIG_ITEM_NULL) + return true; + + hard_assert (item->type == CONFIG_ITEM_INTEGER); + if (item->value.integer >= 0) + return true; + + error_set (e, "must be non-negative"); + return false; +} + +static struct config_schema g_config_server[] = +{ + { .name = "nicks", + .comment = "IRC nickname", + .type = CONFIG_ITEM_STRING_ARRAY, + .validate = config_validate_nonjunk_string }, + { .name = "username", + .comment = "IRC user name", + .type = CONFIG_ITEM_STRING, + .validate = config_validate_nonjunk_string }, + { .name = "realname", + .comment = "IRC real name/e-mail", + .type = CONFIG_ITEM_STRING, + .validate = config_validate_nonjunk_string }, + + { .name = "addresses", + .comment = "Addresses of the IRC network (e.g. \"irc.net:6667\")", + .type = CONFIG_ITEM_STRING_ARRAY, + .validate = config_validate_addresses }, + { .name = "password", + .comment = "Password to connect to the server, if any", + .type = CONFIG_ITEM_STRING, + .validate = config_validate_nonjunk_string }, + // XXX: if we add support for new capabilities, the value stays unchanged + { .name = "capabilities", + .comment = "Capabilities to use if supported by server", + .type = CONFIG_ITEM_STRING_ARRAY, + .validate = config_validate_nonjunk_string, + .default_ = "\"multi-prefix,invite-notify,server-time,echo-message," + "message-tags,away-notify,cap-notify,chghost\"" }, + + { .name = "tls", + .comment = "Whether to use TLS", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "off" }, + { .name = "tls_cert", + .comment = "Client TLS certificate (PEM)", + .type = CONFIG_ITEM_STRING }, + { .name = "tls_verify", + .comment = "Whether to verify certificates", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "on" }, + { .name = "tls_ca_file", + .comment = "OpenSSL CA bundle file", + .type = CONFIG_ITEM_STRING }, + { .name = "tls_ca_path", + .comment = "OpenSSL CA bundle path", + .type = CONFIG_ITEM_STRING }, + { .name = "tls_ciphers", + .comment = "OpenSSL cipher preference list", + .type = CONFIG_ITEM_STRING, + .default_ = "\"DEFAULT:!MEDIUM:!LOW\"" }, + + { .name = "autoconnect", + .comment = "Connect automatically on startup", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "on" }, + { .name = "autojoin", + .comment = "Channels to join on start (e.g. \"#abc,#def key,#ghi\")", + .type = CONFIG_ITEM_STRING_ARRAY, + .validate = config_validate_nonjunk_string }, + { .name = "command", + .comment = "Command to execute after a successful connect", + .type = CONFIG_ITEM_STRING }, + { .name = "command_delay", + .comment = "Delay between executing \"command\" and joining channels", + .type = CONFIG_ITEM_INTEGER, + .validate = config_validate_nonnegative, + .default_ = "0" }, + { .name = "reconnect", + .comment = "Whether to reconnect on error", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "on" }, + { .name = "reconnect_delay", + .comment = "Time between reconnecting", + .type = CONFIG_ITEM_INTEGER, + .validate = config_validate_nonnegative, + .default_ = "5" }, + + { .name = "socks_host", + .comment = "Address of a SOCKS 4a/5 proxy", + .type = CONFIG_ITEM_STRING, + .validate = config_validate_nonjunk_string }, + { .name = "socks_port", + .comment = "SOCKS port number", + .type = CONFIG_ITEM_INTEGER, + .validate = config_validate_nonnegative, + .default_ = "1080" }, + { .name = "socks_username", + .comment = "SOCKS auth. username", + .type = CONFIG_ITEM_STRING }, + { .name = "socks_password", + .comment = "SOCKS auth. password", + .type = CONFIG_ITEM_STRING }, + {} +}; + +static struct config_schema g_config_general[] = +{ + { .name = "autosave", + .comment = "Save configuration automatically after each change", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "on" }, + { .name = "debug_mode", + .comment = "Produce some debugging output", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "off", + .on_change = on_config_debug_mode_change }, + { .name = "logging", + .comment = "Log buffer contents to file", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "off", + .on_change = on_config_logging_change }, + { .name = "plugin_autoload", + .comment = "Plugins to automatically load on start", + .type = CONFIG_ITEM_STRING_ARRAY, + .validate = config_validate_nonjunk_string }, + { .name = "relay_bind", + .comment = "Address to bind to for a user interface relay point", + .type = CONFIG_ITEM_STRING, + .validate = config_validate_nonjunk_string, + .on_change = on_config_relay_bind_change }, + + // Buffer history: + { .name = "backlog_limit", + .comment = "Maximum number of lines stored in the backlog", + .type = CONFIG_ITEM_INTEGER, + .validate = config_validate_nonnegative, + .default_ = "1000", + .on_change = on_config_backlog_limit_change }, + { .name = "pager", + .comment = "Shell command to page buffer history (args: name [path])", + .type = CONFIG_ITEM_STRING, + .default_ = "`name=$(echo \"$1\" | sed 's/[%?:.]/\\\\&/g'); " + "prompt='?f%F:'$name'. ?db- page %db?L of %D. .(?eEND:?PB%PB\\%..)'; " + "LESSSECURE=1 less +Gb -Ps\"$prompt\" \"${2:--R}\"`" }, + { .name = "pager_strip_formatting", + .comment = "Strip terminal formatting from pager input", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "off" }, + + // Output adjustments: + { .name = "beep_on_highlight", + .comment = "Ring the bell when highlighted or on a new invisible PM", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "on", + .on_change = on_config_beep_on_highlight_change }, + { .name = "date_change_line", + .comment = "Input to strftime(3) for the date change line", + .type = CONFIG_ITEM_STRING, + .default_ = "\"%F\"" }, + { .name = "isolate_buffers", + .comment = "Don't leak messages from the server and global buffers", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "off", + .on_change = on_config_isolate_buffers_change }, + { .name = "read_marker_char", + .comment = "The character to use for the read marker line", + .type = CONFIG_ITEM_STRING, + .default_ = "\"-\"", + .validate = config_validate_nonjunk_string }, + { .name = "show_all_prefixes", + .comment = "Show all prefixes in front of nicknames", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "off", + .on_change = on_config_show_all_prefixes_change }, + { .name = "word_wrapping", + .comment = "Enable simple word wrapping in buffers", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "on", + .on_change = on_config_word_wrapping_change }, + + // User input: + { .name = "editor", + .comment = "VIM: \"vim +%Bgo %F\", Emacs: \"emacs -nw +%L:%C %F\", " + "nano/micro/kakoune: \"nano/micro/kak +%L:%C %F\"", + .type = CONFIG_ITEM_STRING }, + { .name = "process_pasted_text", + .comment = "Normalize newlines and quote the command prefix in pastes", + .type = CONFIG_ITEM_BOOLEAN, + .default_ = "on" }, + + // Pan-server configuration: + { .name = "autoaway_message", + .comment = "Automated away message", + .type = CONFIG_ITEM_STRING, + .default_ = "\"I'm not here right now\"" }, + { .name = "autoaway_delay", + .comment = "Delay from the last keypress in seconds", + .type = CONFIG_ITEM_INTEGER, + .validate = config_validate_nonnegative, + .default_ = "1800" }, + { .name = "reconnect_delay_growing", + .comment = "Growth factor for the reconnect delay", + .type = CONFIG_ITEM_INTEGER, + .validate = config_validate_nonnegative, + .default_ = "2" }, + { .name = "reconnect_delay_max", + .comment = "Maximum reconnect delay in seconds", + .type = CONFIG_ITEM_INTEGER, + .validate = config_validate_nonnegative, + .default_ = "600" }, + {} +}; + +static struct config_schema g_config_theme[] = +{ +#define XX(x, y, z) { .name = #y, .comment = #z, .type = CONFIG_ITEM_STRING, \ + .on_change = on_config_theme_change }, + ATTR_TABLE (XX) +#undef XX + {} +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +load_config_general (struct config_item *subtree, void *user_data) +{ + config_schema_apply_to_object (g_config_general, subtree, user_data); +} + +static void +load_config_theme (struct config_item *subtree, void *user_data) +{ + config_schema_apply_to_object (g_config_theme, subtree, user_data); +} + +static void +register_config_modules (struct app_context *ctx) +{ + struct config *config = &ctx->config; + // The servers are loaded later when we can create buffers for them + config_register_module (config, "servers", NULL, NULL); + config_register_module (config, "aliases", NULL, NULL); + config_register_module (config, "plugins", NULL, NULL); + config_register_module (config, "general", load_config_general, ctx); + config_register_module (config, "theme", load_config_theme, ctx); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static const char * +get_config_string (struct config_item *root, const char *key) +{ + struct config_item *item = config_item_get (root, key, NULL); + hard_assert (item); + if (item->type == CONFIG_ITEM_NULL) + return NULL; + hard_assert (config_item_type_is_string (item->type)); + return item->value.string.str; +} + +static bool +set_config_string + (struct config_item *root, const char *key, const char *value) +{ + struct config_item *item = config_item_get (root, key, NULL); + hard_assert (item); + + struct config_item *new_ = config_item_string_from_cstr (value); + struct error *e = NULL; + if (config_item_set_from (item, new_, &e)) + return true; + + config_item_destroy (new_); + print_error ("couldn't set `%s' in configuration: %s", key, e->message); + error_free (e); + return false; +} + +static int64_t +get_config_integer (struct config_item *root, const char *key) +{ + struct config_item *item = config_item_get (root, key, NULL); + hard_assert (item && item->type == CONFIG_ITEM_INTEGER); + return item->value.integer; +} + +static bool +get_config_boolean (struct config_item *root, const char *key) +{ + struct config_item *item = config_item_get (root, key, NULL); + hard_assert (item && item->type == CONFIG_ITEM_BOOLEAN); + return item->value.boolean; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static struct str_map * +get_servers_config (struct app_context *ctx) +{ + return &config_item_get (ctx->config.root, "servers", NULL)->value.object; +} + +static struct str_map * +get_aliases_config (struct app_context *ctx) +{ + return &config_item_get (ctx->config.root, "aliases", NULL)->value.object; +} + +static struct str_map * +get_plugins_config (struct app_context *ctx) +{ + return &config_item_get (ctx->config.root, "plugins", NULL)->value.object; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +serialize_configuration (struct config_item *root, struct str *output) +{ + str_append (output, + "# " PROGRAM_NAME " " PROGRAM_VERSION " configuration file\n" + "#\n" + "# Relative paths are searched for in ${XDG_CONFIG_HOME:-~/.config}\n" + "# /" PROGRAM_NAME " as well as in $XDG_CONFIG_DIRS/" PROGRAM_NAME "\n" + "#\n" + "# Everything is in UTF-8. Any custom comments will be overwritten.\n" + "\n"); + + config_item_write (root, true, output); +} + +// --- Relay output ------------------------------------------------------------ + +static void +client_kill (struct client *c) +{ + struct app_context *ctx = c->ctx; + poller_fd_reset (&c->socket_event); + xclose (c->socket_fd); + c->socket_fd = -1; + + LIST_UNLINK (ctx->clients, c); + client_destroy (c); +} + +static void +client_update_poller (struct client *c, const struct pollfd *pfd) +{ + int new_events = POLLIN; + if (c->write_buffer.len) + new_events |= POLLOUT; + + hard_assert (new_events != 0); + if (!pfd || pfd->events != new_events) + poller_fd_set (&c->socket_event, new_events); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +relay_send (struct client *c) +{ + struct relay_event_message *m = &c->ctx->relay_message; + m->event_seq = c->event_seq++; + + // TODO: Also don't try sending anything if half-closed. + if (!c->initialized || c->socket_fd == -1) + return; + + // liberty has msg_{reader,writer} already, but they use 8-byte lengths. + size_t frame_len_pos = c->write_buffer.len, frame_len = 0; + str_pack_u32 (&c->write_buffer, 0); + if (!relay_event_message_serialize (m, &c->write_buffer) + || (frame_len = c->write_buffer.len - frame_len_pos - 4) > UINT32_MAX) + { + print_error ("serialization failed, killing client"); + client_kill (c); + return; + } + + uint32_t len = htonl (frame_len); + memcpy (c->write_buffer.str + frame_len_pos, &len, sizeof len); + client_update_poller (c, NULL); +} + +static void +relay_broadcast (struct app_context *ctx) +{ + LIST_FOR_EACH (struct client, c, ctx->clients) + relay_send (c); +} + +static struct relay_event_message * +relay_prepare (struct app_context *ctx) +{ + struct relay_event_message *m = &ctx->relay_message; + relay_event_message_free (m); + memset (m, 0, sizeof *m); + return m; +} + +static void +relay_prepare_ping (struct app_context *ctx) +{ + relay_prepare (ctx)->data.event = RELAY_EVENT_PING; +} + +static union relay_item_data * +relay_translate_formatter (struct app_context *ctx, union relay_item_data *p, + const struct formatter_item *i) +{ + // XXX: See attr_printer_decode_color(), this is a footgun. + int16_t c16 = i->color; + int16_t c256 = i->color >> 16; + + unsigned attrs = i->attribute; + switch (i->type) + { + case FORMATTER_ITEM_TEXT: + p->text.text = str_from_cstr (i->text); + (p++)->kind = RELAY_ITEM_TEXT; + break; + case FORMATTER_ITEM_FG_COLOR: + p->fg_color.color = c256 <= 0 ? c16 : c256; + (p++)->kind = RELAY_ITEM_FG_COLOR; + break; + case FORMATTER_ITEM_BG_COLOR: + p->bg_color.color = c256 <= 0 ? c16 : c256; + (p++)->kind = RELAY_ITEM_BG_COLOR; + break; + case FORMATTER_ITEM_ATTR: + (p++)->kind = RELAY_ITEM_RESET; + if ((c256 = ctx->theme[i->attribute].fg) >= 0) + { + p->fg_color.color = c256; + (p++)->kind = RELAY_ITEM_FG_COLOR; + } + if ((c256 = ctx->theme[i->attribute].bg) >= 0) + { + p->bg_color.color = c256; + (p++)->kind = RELAY_ITEM_BG_COLOR; + } + + attrs = ctx->theme[i->attribute].attrs; + // Fall-through + case FORMATTER_ITEM_SIMPLE: + if (attrs & TEXT_BOLD) + (p++)->kind = RELAY_ITEM_FLIP_BOLD; + if (attrs & TEXT_ITALIC) + (p++)->kind = RELAY_ITEM_FLIP_ITALIC; + if (attrs & TEXT_UNDERLINE) + (p++)->kind = RELAY_ITEM_FLIP_UNDERLINE; + if (attrs & TEXT_INVERSE) + (p++)->kind = RELAY_ITEM_FLIP_INVERSE; + if (attrs & TEXT_CROSSED_OUT) + (p++)->kind = RELAY_ITEM_FLIP_CROSSED_OUT; + if (attrs & TEXT_MONOSPACE) + (p++)->kind = RELAY_ITEM_FLIP_MONOSPACE; + break; + default: + break; + } + return p; +} + +static union relay_item_data * +relay_items (struct app_context *ctx, const struct formatter_item *items, + uint32_t *len) +{ + size_t items_len = 0; + for (size_t i = 0; items[i].type; i++) + items_len++; + + // Beware of the upper bound, currently dominated by FORMATTER_ITEM_ATTR. + union relay_item_data *a = xcalloc (items_len * 9, sizeof *a), *p = a; + for (const struct formatter_item *i = items; items_len--; i++) + p = relay_translate_formatter (ctx, p, i); + + *len = p - a; + return a; +} + +static void +relay_prepare_buffer_line (struct app_context *ctx, struct buffer *buffer, + struct buffer_line *line, bool leak_to_active) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_line *e = &m->data.buffer_line; + e->event = RELAY_EVENT_BUFFER_LINE; + e->buffer_name = str_from_cstr (buffer->name); + e->is_unimportant = !!(line->flags & BUFFER_LINE_UNIMPORTANT); + e->is_highlight = !!(line->flags & BUFFER_LINE_HIGHLIGHT); + e->rendition = 1 + line->r; + e->when = line->when * 1000; + e->leak_to_active = leak_to_active; + e->items = relay_items (ctx, line->items, &e->items_len); +} + +// TODO: Consider pushing this whole block of code much further down. +static void formatter_add (struct formatter *self, const char *format, ...); +static char *irc_to_utf8 (const char *text); + +static void +relay_prepare_channel_buffer_update (struct app_context *ctx, + struct buffer *buffer, struct relay_buffer_context_channel *e) +{ + struct channel *channel = buffer->channel; + struct formatter f = formatter_make (ctx, buffer->server); + if (channel->topic) + formatter_add (&f, "#m", channel->topic); + e->topic = relay_items (ctx, f.items, &e->topic_len); + formatter_free (&f); + + // As in make_prompt(), conceal the last known channel modes. + // XXX: This should use irc_channel_is_joined(). + if (!channel->users_len) + return; + + struct str modes = str_make (); + str_append_str (&modes, &channel->no_param_modes); + + struct str params = str_make (); + struct str_map_iter iter = str_map_iter_make (&channel->param_modes); + const char *param; + while ((param = str_map_iter_next (&iter))) + { + str_append_c (&modes, iter.link->key[0]); + str_append_c (¶ms, ' '); + str_append (¶ms, param); + } + + str_append_str (&modes, ¶ms); + str_free (¶ms); + + char *modes_utf8 = irc_to_utf8 (modes.str); + str_free (&modes); + e->modes = str_from_cstr (modes_utf8); + free (modes_utf8); +} + +static void +relay_prepare_buffer_update (struct app_context *ctx, struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_update *e = &m->data.buffer_update; + e->event = RELAY_EVENT_BUFFER_UPDATE; + e->buffer_name = str_from_cstr (buffer->name); + e->hide_unimportant = buffer->hide_unimportant; + + struct str *server_name = NULL; + switch (buffer->type) + { + case BUFFER_GLOBAL: + e->context.kind = RELAY_BUFFER_KIND_GLOBAL; + break; + case BUFFER_SERVER: + e->context.kind = RELAY_BUFFER_KIND_SERVER; + server_name = &e->context.server.server_name; + break; + case BUFFER_CHANNEL: + e->context.kind = RELAY_BUFFER_KIND_CHANNEL; + server_name = &e->context.channel.server_name; + relay_prepare_channel_buffer_update (ctx, buffer, &e->context.channel); + break; + case BUFFER_PM: + e->context.kind = RELAY_BUFFER_KIND_PRIVATE_MESSAGE; + server_name = &e->context.private_message.server_name; + break; + } + if (server_name) + *server_name = str_from_cstr (buffer->server->name); +} + +static void +relay_prepare_buffer_stats (struct app_context *ctx, struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_stats *e = &m->data.buffer_stats; + e->event = RELAY_EVENT_BUFFER_STATS; + e->buffer_name = str_from_cstr (buffer->name); + e->new_messages = MIN (UINT32_MAX, + buffer->new_messages_count - buffer->new_unimportant_count); + e->new_unimportant_messages = MIN (UINT32_MAX, + buffer->new_unimportant_count); + e->highlighted = buffer->highlighted; +} + +static void +relay_prepare_buffer_rename (struct app_context *ctx, struct buffer *buffer, + const char *new_name) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_rename *e = &m->data.buffer_rename; + e->event = RELAY_EVENT_BUFFER_RENAME; + e->buffer_name = str_from_cstr (buffer->name); + e->new = str_from_cstr (new_name); +} + +static void +relay_prepare_buffer_remove (struct app_context *ctx, struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_remove *e = &m->data.buffer_remove; + e->event = RELAY_EVENT_BUFFER_REMOVE; + e->buffer_name = str_from_cstr (buffer->name); +} + +static void +relay_prepare_buffer_activate (struct app_context *ctx, struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_activate *e = &m->data.buffer_activate; + e->event = RELAY_EVENT_BUFFER_ACTIVATE; + e->buffer_name = str_from_cstr (buffer->name); +} + +static void +relay_prepare_buffer_clear (struct app_context *ctx, + struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_clear *e = &m->data.buffer_clear; + e->event = RELAY_EVENT_BUFFER_CLEAR; + e->buffer_name = str_from_cstr (buffer->name); +} + +enum relay_server_state +relay_server_state_for_server (struct server *s) +{ + switch (s->state) + { + case IRC_DISCONNECTED: return RELAY_SERVER_STATE_DISCONNECTED; + case IRC_CONNECTING: return RELAY_SERVER_STATE_CONNECTING; + case IRC_CONNECTED: return RELAY_SERVER_STATE_CONNECTED; + case IRC_REGISTERED: return RELAY_SERVER_STATE_REGISTERED; + case IRC_CLOSING: + case IRC_HALF_CLOSED: return RELAY_SERVER_STATE_DISCONNECTING; + } + return 0; +} + +static void +relay_prepare_server_update (struct app_context *ctx, struct server *s) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_server_update *e = &m->data.server_update; + e->event = RELAY_EVENT_SERVER_UPDATE; + e->server_name = str_from_cstr (s->name); + e->data.state = relay_server_state_for_server (s); + if (s->state == IRC_REGISTERED) + { + char *user_utf8 = irc_to_utf8 (s->irc_user->nickname); + e->data.registered.user = str_from_cstr (user_utf8); + free (user_utf8); + + char *user_modes_utf8 = irc_to_utf8 (s->irc_user_modes.str); + e->data.registered.user_modes = str_from_cstr (user_modes_utf8); + free (user_modes_utf8); + } +} + +static void +relay_prepare_server_rename (struct app_context *ctx, struct server *s, + const char *new_name) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_server_rename *e = &m->data.server_rename; + e->event = RELAY_EVENT_SERVER_RENAME; + e->server_name = str_from_cstr (s->name); + e->new = str_from_cstr (new_name); +} + +static void +relay_prepare_server_remove (struct app_context *ctx, struct server *s) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_server_remove *e = &m->data.server_remove; + e->event = RELAY_EVENT_SERVER_REMOVE; + e->server_name = str_from_cstr (s->name); +} + +static void +relay_prepare_error (struct app_context *ctx, uint32_t seq, const char *message) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_error *e = &m->data.error; + e->event = RELAY_EVENT_ERROR; + e->command_seq = seq; + e->error = str_from_cstr (message); +} + +static struct relay_event_data_response * +relay_prepare_response (struct app_context *ctx, uint32_t seq) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_response *e = &m->data.response; + e->event = RELAY_EVENT_RESPONSE; + e->command_seq = seq; + return e; +} + +// --- Terminal output --------------------------------------------------------- + +/// Default colour pair +#define COLOR_DEFAULT -1 + +/// Bright versions of the basic colour set +#define COLOR_BRIGHT(x) (COLOR_ ## x + 8) + +/// Builds a colour pair for 256-colour terminals with a 16-colour backup value +#define COLOR_256(name, c256) \ + (((COLOR_ ## name) & 0xFFFF) | (((c256) & 0xFFFF) << 16)) + +typedef int (*terminal_printer_fn) (int); + +static int +putchar_stderr (int c) +{ + return fputc (c, stderr); +} + +static terminal_printer_fn +get_attribute_printer (FILE *stream) +{ + if (stream == stdout && g_terminal.stdout_is_tty) + return putchar; + if (stream == stderr && g_terminal.stderr_is_tty) + return putchar_stderr; + return NULL; +} + +// ~~~ Attribute printer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +// A little tool that tries to make the most of the terminal's capabilities +// to set up text attributes. It mostly targets just terminal emulators as that +// is what people are using these days. At least no stupid ncurses limits us +// with colour pairs. + +struct attr_printer +{ + struct attrs *attrs; ///< Named attributes + FILE *stream; ///< Output stream + bool dirty; ///< Attributes are set +}; + +#define ATTR_PRINTER_INIT(attrs, stream) { attrs, stream, true } + +static void +attr_printer_filtered_puts (FILE *stream, const char *attr) +{ + for (; *attr; attr++) + { + // sgr/set_attributes and sgr0/exit_attribute_mode like to enable or + // disable the ACS with SO/SI (e.g. for TERM=screen), however `less -R` + // does not skip over these characters and it screws up word wrapping + if (*attr == 14 /* SO */ || *attr == 15 /* SI */) + continue; + + // Trivially skip delay sequences intended to be processed by tputs() + const char *end = NULL; + if (attr[0] == '$' && attr[1] == '<' && (end = strchr (attr, '>'))) + attr = end; + else + fputc (*attr, stream); + } +} + +static void +attr_printer_tputs (struct attr_printer *self, const char *attr) +{ + terminal_printer_fn printer = get_attribute_printer (self->stream); + if (printer) + tputs (attr, 1, printer); + else + // We shouldn't really do this but we need it to output formatting + // to the pager--it should be SGR-only + attr_printer_filtered_puts (self->stream, attr); +} + +static void +attr_printer_reset (struct attr_printer *self) +{ + if (self->dirty) + attr_printer_tputs (self, exit_attribute_mode); + + self->dirty = false; +} + +// NOTE: commonly terminals have: +// 8 colours (worst, bright fg often with BOLD, bg sometimes with BLINK) +// 16 colours (okayish, we have the full basic range guaranteed) +// 88 colours (the same plus a 4^3 RGB cube and a few shades of grey) +// 256 colours (best, like above but with a larger cube and more grey) + +/// Interpolate from the 256-colour palette to the 88-colour one +static int +attr_printer_256_to_88 (int color) +{ + // These colours are the same everywhere + if (color < 16) + return color; + + // 24 -> 8 extra shades of grey + if (color >= 232) + return 80 + (color - 232) / 3; + + // 6 * 6 * 6 cube -> 4 * 4 * 4 cube + int x[6] = { 0, 1, 1, 2, 2, 3 }; + int index = color - 16; + return 16 + + ( x[ index / 36 ] << 8 + | x[(index / 6) % 6 ] << 4 + | x[(index % 6) ] ); +} + +static int +attr_printer_decode_color (int color, bool *is_bright) +{ + int16_t c16 = color; hard_assert (c16 < 16); + int16_t c256 = color >> 16; hard_assert (c256 < 256); + + *is_bright = false; + switch (max_colors) + { + case 8: + if (c16 >= 8) + { + c16 -= 8; + *is_bright = true; + } + // Fall-through + case 16: + return c16; + + case 88: + return c256 <= 0 ? c16 : attr_printer_256_to_88 (c256); + case 256: + return c256 <= 0 ? c16 : c256; + + default: + // Unsupported palette + return -1; + } +} + +static void +attr_printer_apply (struct attr_printer *self, + int text_attrs, int wanted_fg, int wanted_bg) +{ + bool fg_is_bright; + int fg = attr_printer_decode_color (wanted_fg, &fg_is_bright); + bool bg_is_bright; + int bg = attr_printer_decode_color (wanted_bg, &bg_is_bright); + + bool have_inverse = !!(text_attrs & TEXT_INVERSE); + if (have_inverse) + { + bool tmp = fg_is_bright; + fg_is_bright = bg_is_bright; + bg_is_bright = tmp; + } + + // In 8 colour mode, some terminals don't support bright backgrounds. + // However, we can make use of the fact that the brightness change caused + // by the bold attribute is retained when inverting the colours. + // This has the downside of making the text bold when it's not supposed + // to be, and we still can't make both colours bright, so it's more of + // an interesting hack rather than anything else. + if (!fg_is_bright && bg_is_bright && have_inverse) + text_attrs |= TEXT_BOLD; + else if (!fg_is_bright && bg_is_bright + && !have_inverse && fg >= 0 && bg >= 0) + { + // As long as none of the colours is the default, we can swap them + int tmp = fg; fg = bg; bg = tmp; + text_attrs |= TEXT_BOLD | TEXT_INVERSE; + } + else + { + // This often works, however... + if (fg_is_bright) text_attrs |= TEXT_BOLD; + // this turns out to be annoying if implemented "correctly" + if (bg_is_bright) text_attrs |= TEXT_BLINK; + } + + attr_printer_reset (self); + + // TEXT_MONOSPACE is unimplemented, for obvious reasons + if (text_attrs) + attr_printer_tputs (self, tparm (set_attributes, + 0, // standout + text_attrs & TEXT_UNDERLINE, + text_attrs & TEXT_INVERSE, + text_attrs & TEXT_BLINK, + 0, // dim + text_attrs & TEXT_BOLD, + 0, // blank + 0, // protect + 0)); // acs + if ((text_attrs & TEXT_ITALIC) && enter_italics_mode) + attr_printer_tputs (self, enter_italics_mode); + + char *smxx = NULL; + if ((text_attrs & TEXT_CROSSED_OUT) + && (smxx = tigetstr ("smxx")) && smxx != (char *) -1) + attr_printer_tputs (self, smxx); + + if (fg >= 0) + attr_printer_tputs (self, g_terminal.color_set_fg[fg]); + if (bg >= 0) + attr_printer_tputs (self, g_terminal.color_set_bg[bg]); + + self->dirty = true; +} + +static void +attr_printer_apply_named (struct attr_printer *self, int attribute) +{ + attr_printer_reset (self); + if (attribute == ATTR_RESET) + return; + + // See the COLOR_256 macro or attr_printer_decode_color(). + struct attrs *a = &self->attrs[attribute]; + attr_printer_apply (self, a->attrs, + a->fg < 16 ? a->fg : (a->fg << 16 | (-1 & 0xFFFF)), + a->bg < 16 ? a->bg : (a->bg << 16 | (-1 & 0xFFFF))); + self->dirty = true; +} + +// ~~~ Logging redirect ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +static void +vprint_attributed (struct app_context *ctx, + FILE *stream, intptr_t attribute, const char *fmt, va_list ap) +{ + terminal_printer_fn printer = get_attribute_printer (stream); + if (!attribute) + printer = NULL; + + struct attr_printer state = ATTR_PRINTER_INIT (ctx->theme, stream); + if (printer) + attr_printer_apply_named (&state, attribute); + + vfprintf (stream, fmt, ap); + + if (printer) + attr_printer_reset (&state); +} + +static void +print_attributed (struct app_context *ctx, + FILE *stream, intptr_t attribute, const char *fmt, ...) +{ + va_list ap; + va_start (ap, fmt); + vprint_attributed (ctx, stream, attribute, fmt, ap); + va_end (ap); +} + +static void +log_message_attributed (void *user_data, const char *quote, const char *fmt, + va_list ap) +{ + FILE *stream = stderr; + struct app_context *ctx = g_ctx; + + CALL (ctx->input, hide); + + print_attributed (ctx, stream, (intptr_t) user_data, "%s", quote); + vprint_attributed (ctx, stream, (intptr_t) user_data, fmt, ap); + fputs ("\n", stream); + + CALL (ctx->input, show); +} + +// ~~~ Theme ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +static ssize_t +attr_by_name (const char *name) +{ + static const char *table[ATTR_COUNT] = + { + NULL, +#define XX(x, y, z) [ATTR_ ## x] = #y, + ATTR_TABLE (XX) +#undef XX + }; + + for (size_t i = 1; i < N_ELEMENTS (table); i++) + if (!strcmp (name, table[i])) + return i; + return -1; +} + +static void +on_config_theme_change (struct config_item *item) +{ + struct app_context *ctx = item->user_data; + ssize_t id = attr_by_name (item->schema->name); + if (id != -1) + { + // TODO: There should be a validator. + ctx->theme[id] = item->type == CONFIG_ITEM_NULL + ? ctx->theme_defaults[id] + : attrs_decode (item->value.string.str); + } +} + +static void +init_colors (struct app_context *ctx) +{ + bool have_ti = init_terminal (); + +#define INIT_ATTR(id, ...) ctx->theme[ATTR_ ## id] = \ + ctx->theme_defaults[ATTR_ ## id] = (struct attrs) { __VA_ARGS__ } + + INIT_ATTR (PROMPT, -1, -1, TEXT_BOLD); + INIT_ATTR (RESET, -1, -1, 0); + INIT_ATTR (DATE_CHANGE, -1, -1, TEXT_BOLD); + INIT_ATTR (READ_MARKER, COLOR_MAGENTA, -1, 0); + INIT_ATTR (WARNING, COLOR_YELLOW, -1, 0); + INIT_ATTR (ERROR, COLOR_RED, -1, 0); + + INIT_ATTR (EXTERNAL, COLOR_WHITE, -1, 0); + INIT_ATTR (TIMESTAMP, COLOR_WHITE, -1, 0); + INIT_ATTR (HIGHLIGHT, COLOR_BRIGHT (YELLOW), COLOR_MAGENTA, TEXT_BOLD); + INIT_ATTR (ACTION, COLOR_RED, -1, 0); + INIT_ATTR (USERHOST, COLOR_CYAN, -1, 0); + INIT_ATTR (JOIN, COLOR_GREEN, -1, 0); + INIT_ATTR (PART, COLOR_RED, -1, 0); + +#undef INIT_ATTR + + // This prevents formatters from obtaining an attribute printer function + if (!have_ti) + { + g_terminal.stdout_is_tty = false; + g_terminal.stderr_is_tty = false; + } + + g_log_message_real = log_message_attributed; +} + +// --- Helpers ----------------------------------------------------------------- + +static int +irc_server_strcmp (struct server *s, const char *a, const char *b) +{ + int x; + while (*a || *b) + if ((x = s->irc_tolower (*a++) - s->irc_tolower (*b++))) + return x; + return 0; +} + +static int +irc_server_strncmp (struct server *s, const char *a, const char *b, size_t n) +{ + int x; + while (n-- && (*a || *b)) + if ((x = s->irc_tolower (*a++) - s->irc_tolower (*b++))) + return x; + return 0; +} + +static char * +irc_cut_nickname (const char *prefix) +{ + return cstr_cut_until (prefix, "!@"); +} + +static const char * +irc_find_userhost (const char *prefix) +{ + const char *p = strchr (prefix, '!'); + return p ? p + 1 : NULL; +} + +static bool +irc_is_this_us (struct server *s, const char *prefix) +{ + // This shouldn't be called before successfully registering. + // Better safe than sorry, though. + if (!s->irc_user) + return false; + + char *nick = irc_cut_nickname (prefix); + bool result = !irc_server_strcmp (s, nick, s->irc_user->nickname); + free (nick); + return result; +} + +static bool +irc_is_channel (struct server *s, const char *ident) +{ + return *ident + && (!!strchr (s->irc_chantypes, *ident) || + !!strchr (s->irc_idchan_prefixes, *ident)); +} + +// Message targets can be prefixed by a character filtering their targets +static const char * +irc_skip_statusmsg (struct server *s, const char *target) +{ + return target + (*target && strchr (s->irc_statusmsg, *target)); +} + +static bool +irc_is_extban (struct server *s, const char *target) +{ + // Some servers have a prefix, and some support negation + if (s->irc_extban_prefix && *target++ != s->irc_extban_prefix) + return false; + if (*target == '~') + target++; + + // XXX: we don't know if it's supposed to have an argument, or not + return *target && strchr (s->irc_extban_types, *target++) + && strchr (":\0", *target); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// As of 2020, everything should be in UTF-8. And if it's not, we'll decode it +// as ISO Latin 1. This function should not be called on the whole message. +static char * +irc_to_utf8 (const char *text) +{ + if (!text) + return NULL; + + // XXX: the validation may be unnecessarily harsh, could do with a lenient + // first pass, then replace any errors with the replacement character + size_t len = strlen (text) + 1; + if (utf8_validate (text, len)) + return xstrdup (text); + + // Windows 1252 redefines several silly C1 control characters as glyphs + static const char c1[32][4] = + { + "\xe2\x82\xac", "\xc2\x81", "\xe2\x80\x9a", "\xc6\x92", + "\xe2\x80\x9e", "\xe2\x80\xa6", "\xe2\x80\xa0", "\xe2\x80\xa1", + "\xcb\x86", "\xe2\x80\xb0", "\xc5\xa0", "\xe2\x80\xb9", + "\xc5\x92", "\xc2\x8d", "\xc5\xbd", "\xc2\x8f", + "\xc2\x90", "\xe2\x80\x98", "\xe2\x80\x99", "\xe2\x80\x9c", + "\xe2\x80\x9d", "\xe2\x80\xa2", "\xe2\x80\x93", "\xe2\x80\x94", + "\xcb\x9c", "\xe2\x84\xa2", "\xc5\xa1", "\xe2\x80\xba", + "\xc5\x93", "\xc2\x9d", "\xc5\xbe", "\xc5\xb8", + }; + + struct str s = str_make (); + for (const char *p = text; *p; p++) + { + int c = *(unsigned char *) p; + if (c < 0x80) + str_append_c (&s, c); + else if (c < 0xA0) + str_append (&s, c1[c & 0x1f]); + else + str_append_data (&s, + (char[]) {0xc0 | (c >> 6), 0x80 | (c & 0x3f)}, 2); + } + return str_steal (&s); +} + +// --- Output formatter -------------------------------------------------------- + +// This complicated piece of code makes attributed text formatting simple. +// We use a printf-inspired syntax to push attributes and text to the object, +// then flush it either to a terminal, or a log file with formatting stripped. +// +// Format strings use a #-quoted notation, to differentiate from printf: +// #s inserts a string (expected to be in UTF-8) +// #d inserts a signed integer +// #l inserts a locale-encoded string +// +// #S inserts a string from the server in an unknown encoding +// #m inserts an IRC-formatted string (auto-resets at boundaries) +// #n cuts the nickname from a string and automatically colours it +// #N is like #n but also appends userhost, if present +// +// #a inserts named attributes (auto-resets) +// #r resets terminal attributes +// #c sets foreground colour +// #C sets background colour +// +// Modifiers: +// & free() the string argument after using it + +static void +formatter_add_item (struct formatter *self, struct formatter_item template_) +{ + // Auto-resetting tends to create unnecessary items, + // which also end up being relayed to frontends, so filter them out. + bool reset = + template_.type == FORMATTER_ITEM_ATTR && + template_.attribute == ATTR_RESET; + if (self->clean && reset) + return; + + self->clean = reset || + (self->clean && template_.type == FORMATTER_ITEM_TEXT); + + if (template_.text) + template_.text = xstrdup (template_.text); + + if (self->items_len == self->items_alloc) + self->items = xreallocarray + (self->items, sizeof *self->items, (self->items_alloc <<= 1)); + self->items[self->items_len++] = template_; +} + +#define FORMATTER_ADD_ITEM(self, type_, ...) formatter_add_item ((self), \ + (struct formatter_item) { .type = FORMATTER_ITEM_ ## type_, __VA_ARGS__ }) + +#define FORMATTER_ADD_RESET(self) \ + FORMATTER_ADD_ITEM ((self), ATTR, .attribute = ATTR_RESET) +#define FORMATTER_ADD_TEXT(self, text_) \ + FORMATTER_ADD_ITEM ((self), TEXT, .text = (text_)) + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +enum +{ + MIRC_WHITE, MIRC_BLACK, MIRC_BLUE, MIRC_GREEN, + MIRC_L_RED, MIRC_RED, MIRC_PURPLE, MIRC_ORANGE, + MIRC_YELLOW, MIRC_L_GREEN, MIRC_CYAN, MIRC_L_CYAN, + MIRC_L_BLUE, MIRC_L_PURPLE, MIRC_GRAY, MIRC_L_GRAY, +}; + +// We use estimates from the 16 colour terminal palette, or the 256 colour cube, +// which is not always available. The mIRC orange colour is only in the cube. + +static const int g_mirc_to_terminal[] = +{ + [MIRC_WHITE] = COLOR_256 (BRIGHT (WHITE), 231), + [MIRC_BLACK] = COLOR_256 (BLACK, 16), + [MIRC_BLUE] = COLOR_256 (BLUE, 19), + [MIRC_GREEN] = COLOR_256 (GREEN, 34), + [MIRC_L_RED] = COLOR_256 (BRIGHT (RED), 196), + [MIRC_RED] = COLOR_256 (RED, 124), + [MIRC_PURPLE] = COLOR_256 (MAGENTA, 127), + [MIRC_ORANGE] = COLOR_256 (BRIGHT (YELLOW), 214), + [MIRC_YELLOW] = COLOR_256 (BRIGHT (YELLOW), 226), + [MIRC_L_GREEN] = COLOR_256 (BRIGHT (GREEN), 46), + [MIRC_CYAN] = COLOR_256 (CYAN, 37), + [MIRC_L_CYAN] = COLOR_256 (BRIGHT (CYAN), 51), + [MIRC_L_BLUE] = COLOR_256 (BRIGHT (BLUE), 21), + [MIRC_L_PURPLE] = COLOR_256 (BRIGHT (MAGENTA),201), + [MIRC_GRAY] = COLOR_256 (BRIGHT (BLACK), 244), + [MIRC_L_GRAY] = COLOR_256 (WHITE, 252), +}; + +// https://modern.ircdocs.horse/formatting.html +// http://anti.teamidiot.de/static/nei/*/extended_mirc_color_proposal.html +static const int16_t g_extra_to_256[100 - 16] = +{ + 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, + 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, + 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, + 196, 208, 226, 154, 46, 86 , 51, 75, 21, 171, 201, 198, + 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, + 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, + 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231, -1 +}; + +static const char * +irc_parse_mirc_color (const char *s, uint8_t *fg, uint8_t *bg) +{ + if (!isdigit_ascii (*s)) + { + *fg = *bg = 99; + return s; + } + + *fg = *s++ - '0'; + if (isdigit_ascii (*s)) + *fg = *fg * 10 + (*s++ - '0'); + + if (*s != ',' || !isdigit_ascii (s[1])) + return s; + s++; + + *bg = *s++ - '0'; + if (isdigit_ascii (*s)) + *bg = *bg * 10 + (*s++ - '0'); + return s; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct irc_char_attrs +{ + uint8_t fg, bg; ///< {Fore,back}ground colour or 99 + uint8_t attributes; ///< TEXT_* flags, except TEXT_BLINK + uint8_t starts_at_boundary; ///< Possible to split here? +}; + +static void +irc_serialize_char_attrs (const struct irc_char_attrs *attrs, struct str *out) +{ + soft_assert (attrs->fg < 100 && attrs->bg < 100); + + if (attrs->fg != 99 || attrs->bg != 99) + { + str_append_printf (out, "\x03%u", attrs->fg); + if (attrs->bg != 99) + str_append_printf (out, ",%02u", attrs->bg); + } + if (attrs->attributes & TEXT_BOLD) str_append_c (out, '\x02'); + if (attrs->attributes & TEXT_ITALIC) str_append_c (out, '\x1d'); + if (attrs->attributes & TEXT_UNDERLINE) str_append_c (out, '\x1f'); + if (attrs->attributes & TEXT_INVERSE) str_append_c (out, '\x16'); + if (attrs->attributes & TEXT_CROSSED_OUT) str_append_c (out, '\x1e'); + if (attrs->attributes & TEXT_MONOSPACE) str_append_c (out, '\x11'); +} + +static int +irc_parse_attribute (char c) +{ + switch (c) + { + case '\x02' /* ^B */: return TEXT_BOLD; + case '\x11' /* ^Q */: return TEXT_MONOSPACE; + case '\x16' /* ^V */: return TEXT_INVERSE; + case '\x1d' /* ^] */: return TEXT_ITALIC; + case '\x1e' /* ^^ */: return TEXT_CROSSED_OUT; + case '\x1f' /* ^_ */: return TEXT_UNDERLINE; + case '\x0f' /* ^O */: return -1; + } + return 0; +} + +// The text needs to be NUL-terminated, and a valid UTF-8 string +static struct irc_char_attrs * +irc_analyze_text (const char *text, size_t len) +{ + struct irc_char_attrs *attrs = xcalloc (len, sizeof *attrs), + blank = { .fg = 99, .bg = 99, .starts_at_boundary = true }, + next = blank, cur = next; + + for (size_t i = 0; i != len; cur = next) + { + const char *start = text; + hard_assert (utf8_decode (&text, len - i) >= 0); + + int attribute = irc_parse_attribute (*start); + if (*start == '\x03') + text = irc_parse_mirc_color (text, &next.fg, &next.bg); + else if (attribute > 0) + next.attributes ^= attribute; + else if (attribute < 0) + next = blank; + + while (start++ != text) + { + attrs[i++] = cur; + cur.starts_at_boundary = false; + } + } + return attrs; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static const char * +formatter_parse_mirc_color (struct formatter *self, const char *s) +{ + uint8_t fg = 255, bg = 255; + s = irc_parse_mirc_color (s, &fg, &bg); + + if (fg < 16) + FORMATTER_ADD_ITEM (self, FG_COLOR, .color = g_mirc_to_terminal[fg]); + else if (fg < 100) + FORMATTER_ADD_ITEM (self, FG_COLOR, + .color = COLOR_256 (DEFAULT, g_extra_to_256[fg - 16])); + + if (bg < 16) + FORMATTER_ADD_ITEM (self, BG_COLOR, .color = g_mirc_to_terminal[bg]); + else if (bg < 100) + FORMATTER_ADD_ITEM (self, BG_COLOR, + .color = COLOR_256 (DEFAULT, g_extra_to_256[bg - 16])); + + return s; +} + +static void +formatter_parse_message (struct formatter *self, const char *s) +{ + FORMATTER_ADD_RESET (self); + + struct str buf = str_make (); + unsigned char c; + while ((c = *s++)) + { + if (buf.len && c < 0x20) + { + FORMATTER_ADD_TEXT (self, buf.str); + str_reset (&buf); + } + + int attribute = irc_parse_attribute (c); + if (c == '\x03') + s = formatter_parse_mirc_color (self, s); + else if (attribute > 0) + FORMATTER_ADD_ITEM (self, SIMPLE, .attribute = attribute); + else if (attribute < 0) + FORMATTER_ADD_RESET (self); + else + str_append_c (&buf, c); + } + + if (buf.len) + FORMATTER_ADD_TEXT (self, buf.str); + + str_free (&buf); + FORMATTER_ADD_RESET (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +formatter_parse_nick (struct formatter *self, const char *s) +{ + // For outgoing messages; maybe we should add a special #t for them + // which would also make us not cut off the userhost part, ever + if (irc_is_channel (self->s, irc_skip_statusmsg (self->s, s))) + { + char *tmp = irc_to_utf8 (s); + FORMATTER_ADD_TEXT (self, tmp); + free (tmp); + return; + } + + char *nick = irc_cut_nickname (s); + int color = siphash_wrapper (nick, strlen (nick)) % 7; + + // Never use the black colour, could become transparent on black terminals; + // white is similarly excluded from the range + if (color == COLOR_BLACK) + color = (uint16_t) -1; + + // Use a colour from the 256-colour cube if available + color |= self->ctx->nick_palette[siphash_wrapper (nick, + strlen (nick)) % self->ctx->nick_palette_len] << 16; + + // We always use the default colour for ourselves + if (self->s && irc_is_this_us (self->s, nick)) + color = -1; + + FORMATTER_ADD_ITEM (self, FG_COLOR, .color = color); + + char *x = irc_to_utf8 (nick); + free (nick); + FORMATTER_ADD_TEXT (self, x); + free (x); + + // Need to reset the colour afterwards + FORMATTER_ADD_ITEM (self, FG_COLOR, .color = -1); +} + +static void +formatter_parse_nick_full (struct formatter *self, const char *s) +{ + formatter_parse_nick (self, s); + + const char *userhost; + if (!(userhost = irc_find_userhost (s))) + return; + + FORMATTER_ADD_TEXT (self, " ("); + FORMATTER_ADD_ITEM (self, ATTR, .attribute = ATTR_USERHOST); + + char *x = irc_to_utf8 (userhost); + FORMATTER_ADD_TEXT (self, x); + free (x); + + FORMATTER_ADD_RESET (self); + FORMATTER_ADD_TEXT (self, ")"); +} + +static const char * +formatter_parse_field (struct formatter *self, + const char *field, struct str *buf, va_list *ap) +{ + bool free_string = false; + char *s = NULL; + char *tmp = NULL; + int c; + +restart: + switch ((c = *field++)) + { + // We can push boring text content to the caller's buffer + // and let it flush the buffer only when it's actually needed + case 'd': + tmp = xstrdup_printf ("%d", va_arg (*ap, int)); + str_append (buf, tmp); + free (tmp); + break; + case 's': + str_append (buf, (s = va_arg (*ap, char *))); + break; + case 'l': + if (!(tmp = iconv_xstrdup (self->ctx->term_to_utf8, + (s = va_arg (*ap, char *)), -1, NULL))) + print_error ("character conversion failed for: %s", "output"); + else + str_append (buf, tmp); + free (tmp); + break; + + case 'S': + tmp = irc_to_utf8 ((s = va_arg (*ap, char *))); + str_append (buf, tmp); + free (tmp); + break; + case 'm': + tmp = irc_to_utf8 ((s = va_arg (*ap, char *))); + formatter_parse_message (self, tmp); + free (tmp); + break; + case 'n': + formatter_parse_nick (self, (s = va_arg (*ap, char *))); + break; + case 'N': + formatter_parse_nick_full (self, (s = va_arg (*ap, char *))); + break; + + case 'a': + FORMATTER_ADD_ITEM (self, ATTR, .attribute = va_arg (*ap, int)); + break; + case 'c': + FORMATTER_ADD_ITEM (self, FG_COLOR, .color = va_arg (*ap, int)); + break; + case 'C': + FORMATTER_ADD_ITEM (self, BG_COLOR, .color = va_arg (*ap, int)); + break; + case 'r': + FORMATTER_ADD_RESET (self); + break; + + default: + if (c == '&' && !free_string) + free_string = true; + else if (c) + hard_assert (!"unexpected format specifier"); + else + hard_assert (!"unexpected end of format string"); + goto restart; + } + + if (free_string) + free (s); + return field; +} + +// I was unable to take a pointer of a bare "va_list" when it was passed in +// as a function argument, so it has to be a pointer from the beginning +static void +formatter_addv (struct formatter *self, const char *format, va_list *ap) +{ + struct str buf = str_make (); + while (*format) + { + if (*format != '#' || *++format == '#') + { + str_append_c (&buf, *format++); + continue; + } + if (buf.len) + { + FORMATTER_ADD_TEXT (self, buf.str); + str_reset (&buf); + } + + format = formatter_parse_field (self, format, &buf, ap); + } + + if (buf.len) + FORMATTER_ADD_TEXT (self, buf.str); + + str_free (&buf); +} + +static void +formatter_add (struct formatter *self, const char *format, ...) +{ + va_list ap; + va_start (ap, format); + formatter_addv (self, format, &ap); + va_end (ap); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct line_char_attrs +{ + short named; ///< Named attribute or -1 + short text; ///< Text attributes + int fg; ///< Foreground colour (-1 for default) + int bg; ///< Background colour (-1 for default) +}; + +// We can get rid of the linked list and do this in one allocation (use strlen() +// for the upper bound)--since we only prepend and/or replace characters, add +// a member to specify the prepended character and how many times to repeat it. +// Tabs may nullify the wide character but it's not necessary. +// +// This would be slighly more optimal but it would also set the algorithm in +// stone and complicate flushing. + +struct line_char +{ + LIST_HEADER (struct line_char) + + wchar_t wide; ///< The character as a wchar_t + int width; ///< Width of the character in cells + struct line_char_attrs attrs; ///< Attributes +}; + +static struct line_char * +line_char_new (wchar_t wc) +{ + struct line_char *self = xcalloc (1, sizeof *self); + self->width = wcwidth ((self->wide = wc)); + + // Typically various control characters + if (self->width < 0) + self->width = 0; + + self->attrs.bg = self->attrs.fg = -1; + self->attrs.named = ATTR_RESET; + return self; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct line_wrap_mark +{ + struct line_char *start; ///< First character + int used; ///< Display cells used +}; + +static void +line_wrap_mark_push (struct line_wrap_mark *mark, struct line_char *c) +{ + if (!mark->start) + mark->start = c; + mark->used += c->width; +} + +struct line_wrap_state +{ + struct line_char *result; ///< Head of result + struct line_char *result_tail; ///< Tail of result + + int line_used; ///< Line length before marks + int line_max; ///< Maximum line length + struct line_wrap_mark chunk; ///< All buffered text + struct line_wrap_mark overflow; ///< Overflowing text +}; + +static void +line_wrap_flush_split (struct line_wrap_state *s, struct line_wrap_mark *before) +{ + struct line_char *nl = line_char_new (L'\n'); + LIST_INSERT_WITH_TAIL (s->result, s->result_tail, nl, before->start); + s->line_used = before->used; +} + +static void +line_wrap_flush (struct line_wrap_state *s, bool force_split) +{ + if (!s->overflow.start) + s->line_used += s->chunk.used; + else if (force_split || s->chunk.used > s->line_max) + { +#ifdef WRAP_UNNECESSARILY + // When the line wraps at the end of the screen and a background colour + // is set, the terminal paints the entire new line with that colour. + // Explicitly inserting a newline with the default attributes fixes it. + line_wrap_flush_split (s, &s->overflow); +#else + // Splitting here breaks link searching mechanisms in some terminals, + // though, so we make a trade-off and let the chunk wrap naturally. + // Fuck terminals, really. + s->line_used = s->overflow.used; +#endif + } + else + // Print the chunk in its entirety on a new line + line_wrap_flush_split (s, &s->chunk); + + memset (&s->chunk, 0, sizeof s->chunk); + memset (&s->overflow, 0, sizeof s->overflow); +} + +static void +line_wrap_nl (struct line_wrap_state *s) +{ + line_wrap_flush (s, true); + struct line_char *nl = line_char_new (L'\n'); + LIST_APPEND_WITH_TAIL (s->result, s->result_tail, nl); + s->line_used = 0; +} + +static void +line_wrap_tab (struct line_wrap_state *s, struct line_char *c) +{ + line_wrap_flush (s, true); + if (s->line_used >= s->line_max) + line_wrap_nl (s); + + // Compute the number of characters needed to get to the next tab stop + int tab_width = ((s->line_used + 8) & ~7) - s->line_used; + // On overflow just fill the rest of the line with spaces + if (s->line_used + tab_width > s->line_max) + tab_width = s->line_max - s->line_used; + + s->line_used += tab_width; + while (tab_width--) + { + struct line_char *space = line_char_new (L' '); + space->attrs = c->attrs; + LIST_APPEND_WITH_TAIL (s->result, s->result_tail, space); + } +} + +static void +line_wrap_push_char (struct line_wrap_state *s, struct line_char *c) +{ + // Note that when processing whitespace here, any non-WS chunk has already + // been flushed, and thus it matters little if we flush with force split + if (wcschr (L"\r\f\v", c->wide)) + /* Skip problematic characters */; + else if (c->wide == L'\n') + line_wrap_nl (s); + else if (c->wide == L'\t') + line_wrap_tab (s, c); + else + goto use_as_is; + free (c); + return; + +use_as_is: + if (s->overflow.start + || s->line_used + s->chunk.used + c->width > s->line_max) + { + if (s->overflow.used + c->width > s->line_max) + { +#ifdef WRAP_UNNECESSARILY + // If the overflow overflows, restart on a new line + line_wrap_nl (s); +#else + // See line_wrap_flush(), we would end up on a new line anyway + line_wrap_flush (s, true); + s->line_used = 0; +#endif + } + else + line_wrap_mark_push (&s->overflow, c); + } + line_wrap_mark_push (&s->chunk, c); + LIST_APPEND_WITH_TAIL (s->result, s->result_tail, c); +} + +/// Basic word wrapping that respects wcwidth(3) and expands tabs. +/// Besides making text easier to read, it also fixes the problem with +/// formatting spilling over the entire new line on line wrap. +static struct line_char * +line_wrap (struct line_char *line, int max_width) +{ + struct line_wrap_state s = { .line_max = max_width }; + bool last_was_word_char = false; + LIST_FOR_EACH (struct line_char, c, line) + { + // Act on the right boundary of (\s*\S+) chunks + bool this_is_word_char = !wcschr (L" \t\r\n\f\v", c->wide); + if (last_was_word_char && !this_is_word_char) + line_wrap_flush (&s, false); + last_was_word_char = this_is_word_char; + + LIST_UNLINK (line, c); + line_wrap_push_char (&s, c); + } + + // Make sure to process the last word and return the modified list + line_wrap_flush (&s, false); + return s.result; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct exploder +{ + struct app_context *ctx; ///< Application context + struct line_char *result; ///< Result + struct line_char *result_tail; ///< Tail of result + struct line_char_attrs attrs; ///< Current attributes +}; + +static bool +explode_formatter_attr (struct exploder *self, struct formatter_item *item) +{ + switch (item->type) + { + case FORMATTER_ITEM_ATTR: + self->attrs.named = item->attribute; + self->attrs.text = 0; + self->attrs.fg = -1; + self->attrs.bg = -1; + return true; + case FORMATTER_ITEM_SIMPLE: + self->attrs.named = -1; + self->attrs.text ^= item->attribute; + return true; + case FORMATTER_ITEM_FG_COLOR: + self->attrs.named = -1; + self->attrs.fg = item->color; + return true; + case FORMATTER_ITEM_BG_COLOR: + self->attrs.named = -1; + self->attrs.bg = item->color; + return true; + default: + return false; + } +} + +static void +explode_text (struct exploder *self, const char *text) +{ + // Throw away any potentially harmful control characters first + struct str filtered = str_make (); + for (const char *p = text; *p; p++) + if (!strchr ("\a\b\x0e\x0f\x1b" /* BEL BS SO SI ESC */, *p)) + str_append_c (&filtered, *p); + + size_t term_len = 0, processed = 0, len; + char *term = iconv_xstrdup (self->ctx->term_from_utf8, + filtered.str, filtered.len + 1, &term_len); + str_free (&filtered); + + mbstate_t ps; + memset (&ps, 0, sizeof ps); + + wchar_t wch; + while ((len = mbrtowc (&wch, term + processed, term_len - processed, &ps))) + { + hard_assert (len != (size_t) -2 && len != (size_t) -1); + hard_assert ((processed += len) <= term_len); + + struct line_char *c = line_char_new (wch); + c->attrs = self->attrs; + LIST_APPEND_WITH_TAIL (self->result, self->result_tail, c); + } + free (term); +} + +static struct line_char * +formatter_to_chars (struct formatter *formatter) +{ + struct exploder self = { .ctx = formatter->ctx }; + self.attrs.fg = self.attrs.bg = self.attrs.named = -1; + + int attribute_ignore = 0; + for (size_t i = 0; i < formatter->items_len; i++) + { + struct formatter_item *iter = &formatter->items[i]; + if (iter->type == FORMATTER_ITEM_TEXT) + explode_text (&self, iter->text); + else if (iter->type == FORMATTER_ITEM_IGNORE_ATTR) + attribute_ignore += iter->attribute; + else if (attribute_ignore <= 0 + && !explode_formatter_attr (&self, iter)) + hard_assert (!"unhandled formatter item type"); + } + return self.result; +} + +enum +{ + FLUSH_OPT_RAW = (1 << 0), ///< Print raw attributes + FLUSH_OPT_NOWRAP = (1 << 1) ///< Do not wrap +}; + +/// The input is a bunch of wide characters--respect shift state encodings +static void +formatter_putc (struct line_char *c, FILE *stream) +{ + static mbstate_t mb; + char buf[MB_LEN_MAX] = {}; + size_t len = wcrtomb (buf, c ? c->wide : L'\0', &mb); + if (len != (size_t) -1 && len) + fwrite (buf, len - !c, 1, stream); + free (c); +} + +static void +formatter_flush (struct formatter *self, FILE *stream, int flush_opts) +{ + struct line_char *line = formatter_to_chars (self); + + bool is_tty = !!get_attribute_printer (stream); + if (!is_tty && !(flush_opts & FLUSH_OPT_RAW)) + { + LIST_FOR_EACH (struct line_char, c, line) + formatter_putc (c, stream); + formatter_putc (NULL, stream); + return; + } + + if (self->ctx->word_wrapping && !(flush_opts & FLUSH_OPT_NOWRAP)) + line = line_wrap (line, g_terminal.columns); + + struct attr_printer state = ATTR_PRINTER_INIT (self->ctx->theme, stream); + struct line_char_attrs attrs = {}; // Won't compare equal to anything + LIST_FOR_EACH (struct line_char, c, line) + { + if (attrs.fg != c->attrs.fg + || attrs.bg != c->attrs.bg + || attrs.named != c->attrs.named + || attrs.text != c->attrs.text) + { + formatter_putc (NULL, stream); + + attrs = c->attrs; + if (attrs.named == -1) + attr_printer_apply (&state, attrs.text, attrs.fg, attrs.bg); + else + attr_printer_apply_named (&state, attrs.named); + } + + formatter_putc (c, stream); + } + formatter_putc (NULL, stream); + attr_printer_reset (&state); +} + +// --- Buffers ----------------------------------------------------------------- + +static void +buffer_pop_excess_lines (struct app_context *ctx, struct buffer *self) +{ + int to_delete = (int) self->lines_count - (int) ctx->backlog_limit; + while (to_delete-- > 0 && self->lines) + { + struct buffer_line *excess = self->lines; + LIST_UNLINK_WITH_TAIL (self->lines, self->lines_tail, excess); + buffer_line_destroy (excess); + self->lines_count--; + } +} + +static void +on_config_backlog_limit_change (struct config_item *item) +{ + struct app_context *ctx = item->user_data; + ctx->backlog_limit = MIN (item->value.integer, INT_MAX); + + LIST_FOR_EACH (struct buffer, iter, ctx->buffers) + buffer_pop_excess_lines (ctx, iter); +} + +static void +buffer_update_time (struct app_context *ctx, time_t now, FILE *stream, + int flush_opts) +{ + struct tm last, current; + if (!localtime_r (&ctx->last_displayed_msg_time, &last) + || !localtime_r (&now, ¤t)) + { + // Strange but nonfatal + print_error ("%s: %s", "localtime_r", strerror (errno)); + return; + } + + ctx->last_displayed_msg_time = now; + if (last.tm_year == current.tm_year + && last.tm_mon == current.tm_mon + && last.tm_mday == current.tm_mday) + return; + + char buf[64] = ""; + const char *format = + get_config_string (ctx->config.root, "general.date_change_line"); + if (!strftime (buf, sizeof buf, format, ¤t)) + { + print_error ("%s: %s", "strftime", strerror (errno)); + return; + } + + struct formatter f = formatter_make (ctx, NULL); + formatter_add (&f, "#a#s\n", ATTR_DATE_CHANGE, buf); + formatter_flush (&f, stream, flush_opts); + // Flush the trailing formatting reset item + fflush (stream); + formatter_free (&f); +} + +static void +buffer_line_flush (struct buffer_line *line, struct formatter *f, FILE *output, + int flush_opts) +{ + switch (line->r) + { + case BUFFER_LINE_BARE: break; + case BUFFER_LINE_INDENT: formatter_add (f, " "); break; + case BUFFER_LINE_STATUS: formatter_add (f, " - "); break; + case BUFFER_LINE_ERROR: formatter_add (f, "#a=!=#r ", ATTR_ERROR); break; + case BUFFER_LINE_JOIN: formatter_add (f, "#a-->#r ", ATTR_JOIN); break; + case BUFFER_LINE_PART: formatter_add (f, "#a<--#r ", ATTR_PART); break; + case BUFFER_LINE_ACTION: formatter_add (f, " #a*#r ", ATTR_ACTION); break; + } + + for (struct formatter_item *iter = line->items; iter->type; iter++) + formatter_add_item (f, *iter); + + formatter_add (f, "\n"); + formatter_flush (f, output, flush_opts); + formatter_free (f); +} + +static void +buffer_line_write_time (struct formatter *f, struct buffer_line *line, + FILE *stream, int flush_opts) +{ + // Normal timestamps don't include the date, make sure the user won't be + // confused as to when an event has happened + buffer_update_time (f->ctx, line->when, stream, flush_opts); + + struct tm current; + char buf[9]; + if (!localtime_r (&line->when, ¤t)) + print_error ("%s: %s", "localtime_r", strerror (errno)); + else if (!strftime (buf, sizeof buf, "%T", ¤t)) + print_error ("%s: %s", "strftime", "buffer too small"); + else + formatter_add (f, "#a#s#r ", ATTR_TIMESTAMP, buf); +} + +#define buffer_line_will_show_up(buffer, line) \ + (!(buffer)->hide_unimportant || !((line)->flags & BUFFER_LINE_UNIMPORTANT)) + +static void +buffer_line_display (struct app_context *ctx, + struct buffer *buffer, struct buffer_line *line, bool is_external) +{ + if (!buffer_line_will_show_up (buffer, line)) + return; + + CALL (ctx->input, hide); + + struct formatter f = formatter_make (ctx, NULL); + buffer_line_write_time (&f, line, stdout, 0); + + // Ignore all formatting for messages coming from other buffers, that is + // either from the global or server buffer. Instead print them in grey. + if (is_external) + { + formatter_add (&f, "#a", ATTR_EXTERNAL); + FORMATTER_ADD_ITEM (&f, IGNORE_ATTR, .attribute = 1); + } + buffer_line_flush (line, &f, stdout, 0); + // Flush the trailing formatting reset item + fflush (stdout); + + CALL (ctx->input, show); +} + +static void +buffer_line_write_to_backlog (struct app_context *ctx, + struct buffer_line *line, FILE *log_file, int flush_opts) +{ + struct formatter f = formatter_make (ctx, NULL); + buffer_line_write_time (&f, line, log_file, flush_opts); + buffer_line_flush (line, &f, log_file, flush_opts); +} + +static void +buffer_line_write_to_log (struct app_context *ctx, + struct buffer_line *line, FILE *log_file) +{ + if (line->flags & BUFFER_LINE_SKIP_FILE) + return; + + struct formatter f = formatter_make (ctx, NULL); + struct tm current; + char buf[20]; + if (!gmtime_r (&line->when, ¤t)) + print_error ("%s: %s", "gmtime_r", strerror (errno)); + else if (!strftime (buf, sizeof buf, "%F %T", ¤t)) + print_error ("%s: %s", "strftime", "buffer too small"); + else + formatter_add (&f, "#s ", buf); + + // The target is not a terminal, thus it won't wrap in spite of the 0 + buffer_line_flush (line, &f, log_file, 0); +} + +static void +log_formatter (struct app_context *ctx, struct buffer *buffer, + unsigned flags, enum buffer_line_rendition r, struct formatter *f) +{ + if (!buffer) + buffer = ctx->global_buffer; + + struct buffer_line *line = buffer_line_new (f); + line->flags = flags; + line->r = r; + // TODO: allow providing custom time (IRCv3.2 server-time) + line->when = time (NULL); + + buffer_pop_excess_lines (ctx, buffer); + LIST_APPEND_WITH_TAIL (buffer->lines, buffer->lines_tail, line); + buffer->lines_count++; + + if (buffer->log_file) + buffer_line_write_to_log (ctx, line, buffer->log_file); + + bool unseen_pm = buffer->type == BUFFER_PM + && buffer != ctx->current_buffer + && !(flags & BUFFER_LINE_UNIMPORTANT); + bool important = (flags & BUFFER_LINE_HIGHLIGHT) || unseen_pm; + if (ctx->beep_on_highlight && important) + // XXX: this may disturb any other foreground process + CALL (ctx->input, ding); + + bool can_leak = false; + if ((buffer == ctx->global_buffer) + || (ctx->current_buffer->type == BUFFER_GLOBAL + && buffer->type == BUFFER_SERVER) + || (ctx->current_buffer->type != BUFFER_GLOBAL + && buffer == ctx->current_buffer->server->buffer)) + can_leak = true; + + relay_prepare_buffer_line (ctx, buffer, line, + buffer != ctx->current_buffer && !ctx->isolate_buffers && can_leak); + relay_broadcast (ctx); + + bool displayed = true; + if (ctx->terminal_suspended > 0) + // Another process is using the terminal + displayed = false; + else if (buffer == ctx->current_buffer) + buffer_line_display (ctx, buffer, line, false); + else if (!ctx->isolate_buffers && can_leak) + buffer_line_display (ctx, buffer, line, true); + else + displayed = false; + + // Advance the unread marker in active buffers but don't create a new one + if (!displayed + || (buffer == ctx->current_buffer && buffer->new_messages_count)) + { + buffer->new_messages_count++; + if (flags & BUFFER_LINE_UNIMPORTANT) + buffer->new_unimportant_count++; + buffer->highlighted |= important; + } + if (!displayed) + refresh_prompt (ctx); +} + +static void +log_full (struct app_context *ctx, struct server *s, struct buffer *buffer, + unsigned flags, enum buffer_line_rendition r, const char *format, ...) +{ + va_list ap; + va_start (ap, format); + + struct formatter f = formatter_make (ctx, s); + formatter_addv (&f, format, &ap); + log_formatter (ctx, buffer, flags, r, &f); + + va_end (ap); +} + +#define log_global(ctx, flags, r, ...) \ + log_full ((ctx), NULL, (ctx)->global_buffer, (flags), (r), __VA_ARGS__) +#define log_server(s, buffer, flags, r, ...) \ + log_full ((s)->ctx, (s), (buffer), (flags), (r), __VA_ARGS__) + +#define log_global_status(ctx, ...) \ + log_global ((ctx), 0, BUFFER_LINE_STATUS, __VA_ARGS__) +#define log_global_error(ctx, ...) \ + log_global ((ctx), 0, BUFFER_LINE_ERROR, __VA_ARGS__) +#define log_global_indent(ctx, ...) \ + log_global ((ctx), 0, BUFFER_LINE_INDENT, __VA_ARGS__) + +#define log_server_status(s, buffer, ...) \ + log_server ((s), (buffer), 0, BUFFER_LINE_STATUS, __VA_ARGS__) +#define log_server_error(s, buffer, ...) \ + log_server ((s), (buffer), 0, BUFFER_LINE_ERROR, __VA_ARGS__) + +#define log_global_debug(ctx, ...) \ + BLOCK_START \ + if (g_debug_mode) \ + log_global ((ctx), 0, 0, "(*) " __VA_ARGS__); \ + BLOCK_END + +#define log_server_debug(s, ...) \ + BLOCK_START \ + if (g_debug_mode) \ + log_server ((s), (s)->buffer, 0, 0, "(*) " __VA_ARGS__); \ + BLOCK_END + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Lines that are used in more than one place + +#define log_nick_self(s, buffer, new_) \ + log_server ((s), (buffer), BUFFER_LINE_UNIMPORTANT, BUFFER_LINE_STATUS, \ + "You are now known as #n", (new_)) +#define log_nick(s, buffer, old, new_) \ + log_server ((s), (buffer), BUFFER_LINE_UNIMPORTANT, BUFFER_LINE_STATUS, \ + "#n is now known as #n", (old), (new_)) + +#define log_chghost_self(s, buffer, new_) \ + log_server ((s), (buffer), BUFFER_LINE_UNIMPORTANT, BUFFER_LINE_STATUS, \ + "You are now #N", (new_)) +#define log_chghost(s, buffer, old, new_) \ + log_server ((s), (buffer), BUFFER_LINE_UNIMPORTANT, BUFFER_LINE_STATUS, \ + "#N is now #N", (old), (new_)) + +#define log_outcoming_notice(s, buffer, who, text) \ + log_server_status ((s), (buffer), "#s(#n): #m", "Notice", (who), (text)) +#define log_outcoming_privmsg(s, buffer, prefixes, who, text) \ + log_server ((s), (buffer), 0, 0, "<#s#n> #m", (prefixes), (who), (text)) +#define log_outcoming_action(s, buffer, who, text) \ + log_server ((s), (buffer), 0, BUFFER_LINE_ACTION, "#n #m", (who), (text)) + +#define log_outcoming_orphan_notice(s, target, text) \ + log_server_status ((s), (s)->buffer, "Notice -> #n: #m", (target), (text)) +#define log_outcoming_orphan_privmsg(s, target, text) \ + log_server ((s), (s)->buffer, 0, BUFFER_LINE_STATUS, \ + "MSG(#n): #m", (target), (text)) +#define log_outcoming_orphan_action(s, target, text) \ + log_server ((s), (s)->buffer, 0, BUFFER_LINE_ACTION, \ + "MSG(#n): #m", (target), (text)) + +#define log_ctcp_query(s, target, tag) \ + log_server_status ((s), (s)->buffer, "CTCP query to #S: #S", target, tag) +#define log_ctcp_reply(s, target, reply /* freed! */) \ + log_server_status ((s), (s)->buffer, "CTCP reply to #S: #&S", target, reply) + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +make_log_filename (const char *filename, struct str *output) +{ + for (const char *p = filename; *p; p++) + // XXX: anything more to replace? + if (strchr ("/\\ ", *p)) + str_append_c (output, '_'); + else + str_append_c (output, tolower_ascii (*p)); +} + +static char * +buffer_get_log_path (struct buffer *buffer) +{ + struct str path = str_make (); + get_xdg_home_dir (&path, "XDG_DATA_HOME", ".local/share"); + str_append_printf (&path, "/%s/%s", PROGRAM_NAME, "logs"); + + (void) mkdir_with_parents (path.str, NULL); + + str_append_c (&path, '/'); + // FIXME: This mixes up character encodings. + make_log_filename (buffer->name, &path); + str_append (&path, ".log"); + return str_steal (&path); +} + +static void +buffer_open_log_file (struct app_context *ctx, struct buffer *buffer) +{ + if (!ctx->logging || buffer->log_file) + return; + + // TODO: should we try to reopen files wrt. case mapping? + // - Need to read the whole directory and look for matches: + // irc_server_strcmp(buffer->s, d_name, make_log_filename()) + // remember to strip the ".log" suffix from d_name, case-sensitively. + // - The tolower_ascii() in make_log_filename() is a perfect overlap, + // it may stay as-is. + // - buffer_get_log_path() will need to return a FILE *, + // or an error that includes the below message. + char *path = buffer_get_log_path (buffer); + if (!(buffer->log_file = fopen (path, "ab"))) + log_global_error (ctx, "Couldn't open log file `#l': #l", + path, strerror (errno)); + else + set_cloexec (fileno (buffer->log_file)); + free (path); +} + +static void +buffer_close_log_file (struct buffer *buffer) +{ + if (buffer->log_file) + (void) fclose (buffer->log_file); + buffer->log_file = NULL; +} + +static void +on_config_logging_change (struct config_item *item) +{ + struct app_context *ctx = item->user_data; + ctx->logging = item->value.boolean; + + for (struct buffer *buffer = ctx->buffers; buffer; buffer = buffer->next) + if (ctx->logging) + buffer_open_log_file (ctx, buffer); + else + buffer_close_log_file (buffer); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static struct buffer * +buffer_by_name (struct app_context *ctx, const char *name) +{ + return str_map_find (&ctx->buffers_by_name, name); +} + +static void +buffer_add (struct app_context *ctx, struct buffer *buffer) +{ + hard_assert (!buffer_by_name (ctx, buffer->name)); + + relay_prepare_buffer_update (ctx, buffer); + relay_broadcast (ctx); + + str_map_set (&ctx->buffers_by_name, buffer->name, buffer); + LIST_APPEND_WITH_TAIL (ctx->buffers, ctx->buffers_tail, buffer); + + buffer_open_log_file (ctx, buffer); + + // Normally this doesn't cause changes in the prompt but a prompt hook + // could decide to show some information for all buffers nonetheless + refresh_prompt (ctx); +} + +static void +buffer_remove (struct app_context *ctx, struct buffer *buffer) +{ + hard_assert (buffer != ctx->current_buffer); + hard_assert (buffer != ctx->global_buffer); + + relay_prepare_buffer_remove (ctx, buffer); + relay_broadcast (ctx); + + CALL_ (ctx->input, buffer_destroy, buffer->input_data); + buffer->input_data = NULL; + + // And make sure to unlink the buffer from "irc_buffer_map" + struct server *s = buffer->server; + if (buffer->channel) + str_map_set (&s->irc_buffer_map, buffer->channel->name, NULL); + if (buffer->user) + str_map_set (&s->irc_buffer_map, buffer->user->nickname, NULL); + + if (buffer == ctx->last_buffer) + ctx->last_buffer = NULL; + if (buffer->type == BUFFER_SERVER) + buffer->server->buffer = NULL; + + str_map_set (&ctx->buffers_by_name, buffer->name, NULL); + LIST_UNLINK_WITH_TAIL (ctx->buffers, ctx->buffers_tail, buffer); + buffer_unref (buffer); + + refresh_prompt (ctx); +} + +static void +buffer_print_read_marker (struct app_context *ctx, FILE *stream, int flush_opts) +{ + struct formatter f = formatter_make (ctx, NULL); + const int timestamp_width = 8; // hardcoded to %T right now, simple + const char *marker_char = + get_config_string (ctx->config.root, "general.read_marker_char"); + + // We could turn this off on FLUSH_OPT_NOWRAP, however our default pager + // wraps lines for us even if we don't do it ourselves, and thus there's + // no need to worry about inconsistency. + if (*marker_char) + { + struct str s = str_make (); + for (int i = 0; i < timestamp_width; i++) + str_append (&s, marker_char); + formatter_add (&f, "#a#s#r", ATTR_TIMESTAMP, s.str); + str_reset (&s); + for (int i = timestamp_width; i < g_terminal.columns; i++) + str_append (&s, marker_char); + formatter_add (&f, "#a#s#r\n", ATTR_READ_MARKER, s.str); + str_free (&s); + } + else + formatter_add (&f, "#a-- -- -- ---\n", ATTR_READ_MARKER); + + formatter_flush (&f, stream, flush_opts); + // Flush the trailing formatting reset item + fflush (stream); + formatter_free (&f); +} + +static void +buffer_print_backlog (struct app_context *ctx, struct buffer *buffer) +{ + // Buffers can be activated, or their lines modified, as automatic actions. + if (ctx->terminal_suspended) + return; + + // The prompt can take considerable time to redraw + CALL (ctx->input, hide); + + // Simulate curses-like fullscreen buffers if the terminal allows it + if (g_terminal.initialized && clear_screen) + { + terminal_printer_fn printer = get_attribute_printer (stdout); + tputs (clear_screen, 1, printer); + if (cursor_to_ll) + tputs (cursor_to_ll, 1, printer); + else if (row_address) + tputs (tparm (row_address, g_terminal.lines - 1, + 0, 0, 0, 0, 0, 0, 0, 0), 1, printer); + else if (cursor_address) + tputs (tparm (cursor_address, g_terminal.lines - 1, + 0, 0, 0, 0, 0, 0, 0, 0), 1, printer); + fflush (stdout); + + // We should update "last_displayed_msg_time" here just to be sure + // that the first date marker, if necessary, is shown, but in practice + // the value should always be from today when this function is called + } + else + { + char *buffer_name_localized = + iconv_xstrdup (ctx->term_from_utf8, buffer->name, -1, NULL); + print_status ("%s", buffer_name_localized); + free (buffer_name_localized); + } + + // That is, minus the readline prompt (taking at least one line) + int display_limit = MAX (10, g_terminal.lines - 1); + int to_display = 0; + + struct buffer_line *line; + for (line = buffer->lines_tail; line; line = line->prev) + { + to_display++; + if (buffer_line_will_show_up (buffer, line)) + display_limit--; + if (!line->prev || display_limit <= 0) + break; + } + + // Once we've found where we want to start with the backlog, print it + int until_marker = to_display - (int) buffer->new_messages_count; + for (; line; line = line->next) + { + if (until_marker-- == 0 + && buffer->new_messages_count != buffer->lines_count) + buffer_print_read_marker (ctx, stdout, 0); + buffer_line_display (ctx, buffer, line, 0); + } + + // So that it is obvious if the last line in the buffer is not from today + buffer_update_time (ctx, time (NULL), stdout, 0); + + refresh_prompt (ctx); + CALL (ctx->input, show); +} + +static void +buffer_activate (struct app_context *ctx, struct buffer *buffer) +{ + if (ctx->current_buffer == buffer) + return; + + relay_prepare_buffer_activate (ctx, buffer); + relay_broadcast (ctx); + + // This is the only place where the unread messages marker + // and highlight indicator are reset + if (ctx->current_buffer) + { + ctx->current_buffer->new_messages_count = 0; + ctx->current_buffer->new_unimportant_count = 0; + ctx->current_buffer->highlighted = false; + } + + buffer_print_backlog (ctx, buffer); + CALL_ (ctx->input, buffer_switch, buffer->input_data); + + // Now at last we can switch the pointers + ctx->last_buffer = ctx->current_buffer; + ctx->current_buffer = buffer; + + refresh_prompt (ctx); +} + +static void +buffer_merge (struct app_context *ctx, + struct buffer *buffer, struct buffer *merged) +{ + // XXX: anything better to do? This situation is arguably rare and I'm + // not entirely sure what action to take. + log_full (ctx, NULL, buffer, 0, BUFFER_LINE_STATUS, + "Buffer #s was merged into this buffer", merged->name); + + // Find all lines from "merged" newer than the newest line in "buffer" + struct buffer_line *start = merged->lines; + if (buffer->lines_tail) + while (start && start->when < buffer->lines_tail->when) + start = start->next; + if (!start) + return; + + // Count how many of them we have + size_t n = 0; + for (struct buffer_line *iter = start; iter; iter = iter->next) + n++; + struct buffer_line *tail = merged->lines_tail; + + // Cut them from the original buffer + if (start == merged->lines) + merged->lines = NULL; + else if (start->prev) + start->prev->next = NULL; + merged->lines_tail = start->prev; + merged->lines_count -= n; + + // Append them to current lines in the buffer + buffer->lines_tail->next = start; + start->prev = buffer->lines_tail; + buffer->lines_tail = tail; + buffer->lines_count += n; + + // And since there is no log_*() call, send them to relays manually + buffer->highlighted |= merged->highlighted; + LIST_FOR_EACH (struct buffer_line, line, start) + { + if (buffer->new_messages_count) + { + buffer->new_messages_count++; + if (line->flags & BUFFER_LINE_UNIMPORTANT) + buffer->new_unimportant_count++; + } + + relay_prepare_buffer_line (ctx, buffer, line, false); + relay_broadcast (ctx); + } + + log_full (ctx, NULL, buffer, BUFFER_LINE_SKIP_FILE, BUFFER_LINE_STATUS, + "End of merged content"); +} + +static void +buffer_rename (struct app_context *ctx, + struct buffer *buffer, const char *new_name) +{ + struct buffer *collision = str_map_find (&ctx->buffers_by_name, new_name); + if (collision == buffer) + return; + + hard_assert (!collision); + + relay_prepare_buffer_rename (ctx, buffer, new_name); + relay_broadcast (ctx); + + str_map_set (&ctx->buffers_by_name, buffer->name, NULL); + str_map_set (&ctx->buffers_by_name, new_name, buffer); + + cstr_set (&buffer->name, xstrdup (new_name)); + + buffer_close_log_file (buffer); + buffer_open_log_file (ctx, buffer); + + // We might have renamed the current buffer + refresh_prompt (ctx); +} + +static void +buffer_clear (struct app_context *ctx, struct buffer *buffer) +{ + relay_prepare_buffer_clear (ctx, buffer); + relay_broadcast (ctx); + + LIST_FOR_EACH (struct buffer_line, iter, buffer->lines) + buffer_line_destroy (iter); + + buffer->lines = buffer->lines_tail = NULL; + buffer->lines_count = 0; +} + +static void +buffer_toggle_unimportant (struct app_context *ctx, struct buffer *buffer) +{ + buffer->hide_unimportant ^= true; + + relay_prepare_buffer_update (ctx, buffer); + relay_broadcast (ctx); + + if (buffer == ctx->current_buffer) + buffer_print_backlog (ctx, buffer); +} + +static struct buffer * +buffer_at_index (struct app_context *ctx, int n) +{ + int i = 0; + LIST_FOR_EACH (struct buffer, iter, ctx->buffers) + if (++i == n) + return iter; + return NULL; +} + +static struct buffer * +buffer_next (struct app_context *ctx, int count) +{ + struct buffer *new_buffer = ctx->current_buffer; + while (count-- > 0) + if (!(new_buffer = new_buffer->next)) + new_buffer = ctx->buffers; + return new_buffer; +} + +static struct buffer * +buffer_previous (struct app_context *ctx, int count) +{ + struct buffer *new_buffer = ctx->current_buffer; + while (count-- > 0) + if (!(new_buffer = new_buffer->prev)) + new_buffer = ctx->buffers_tail; + return new_buffer; +} + +static bool +buffer_goto (struct app_context *ctx, int n) +{ + struct buffer *buffer = buffer_at_index (ctx, n); + if (!buffer) + return false; + + buffer_activate (ctx, buffer); + return true; +} + +static int +buffer_count (struct app_context *ctx) +{ + int total = 0; + LIST_FOR_EACH (struct buffer, iter, ctx->buffers) + total++; + return total; +} + +static void +buffer_move (struct app_context *ctx, struct buffer *buffer, int n) +{ + hard_assert (n >= 1 && n <= buffer_count (ctx)); + LIST_UNLINK_WITH_TAIL (ctx->buffers, ctx->buffers_tail, buffer); + + struct buffer *following = ctx->buffers; + while (--n && following) + following = following->next; + + LIST_INSERT_WITH_TAIL (ctx->buffers, ctx->buffers_tail, buffer, following); + refresh_prompt (ctx); +} + +static int +buffer_get_index (struct app_context *ctx, struct buffer *buffer) +{ + int index = 1; + LIST_FOR_EACH (struct buffer, iter, ctx->buffers) + { + if (iter == buffer) + return index; + index++; + } + return -1; +} + +static void +buffer_remove_safe (struct app_context *ctx, struct buffer *buffer) +{ + if (buffer == ctx->current_buffer) + buffer_activate (ctx, ctx->last_buffer + ? ctx->last_buffer + : buffer_next (ctx, 1)); + buffer_remove (ctx, buffer); +} + +static void +init_global_buffer (struct app_context *ctx) +{ + struct buffer *global = ctx->global_buffer = + buffer_new (ctx->input, BUFFER_GLOBAL, xstrdup (PROGRAM_NAME)); + + buffer_add (ctx, global); + buffer_activate (ctx, global); +} + +// --- Users, channels --------------------------------------------------------- + +static char * +irc_make_buffer_name (struct server *s, const char *target) +{ + if (!target) + return xstrdup (s->name); + + char *target_utf8 = irc_to_utf8 (target); + char *name = xstrdup_printf ("%s.%s", s->name, target_utf8); + free (target_utf8); + + struct buffer *conflict = buffer_by_name (s->ctx, name); + if (!conflict) + return name; + + hard_assert (conflict->server == s); + + // Fix up any conflicts. Note that while parentheses aren't allowed + // in IRC nicknames, they may occur in channel names. + int i = 0; + char *unique = xstrdup_printf ("%s(%d)", name, ++i); + while (buffer_by_name (s->ctx, unique)) + cstr_set (&unique, xstrdup_printf ("%s(%d)", name, ++i)); + free (name); + return unique; +} + +static void +irc_user_on_destroy (void *object, void *user_data) +{ + struct user *user = object; + struct server *s = user_data; + if (!s->rehashing) + str_map_set (&s->irc_users, user->nickname, NULL); +} + +static struct user * +irc_make_user (struct server *s, char *nickname) +{ + hard_assert (!str_map_find (&s->irc_users, nickname)); + + struct user *user = user_new (nickname); + (void) user_weak_ref (user, irc_user_on_destroy, s); + str_map_set (&s->irc_users, user->nickname, user); + return user; +} + +struct user * +irc_get_or_make_user (struct server *s, const char *nickname) +{ + struct user *user = str_map_find (&s->irc_users, nickname); + if (user) + return user_ref (user); + return irc_make_user (s, xstrdup (nickname)); +} + +static struct buffer * +irc_get_or_make_user_buffer (struct server *s, const char *nickname) +{ + struct buffer *buffer = str_map_find (&s->irc_buffer_map, nickname); + if (buffer) + return buffer; + + struct user *user = irc_get_or_make_user (s, nickname); + + // Open a new buffer for the user + buffer = buffer_new (s->ctx->input, + BUFFER_PM, irc_make_buffer_name (s, nickname)); + buffer->server = s; + buffer->user = user; + str_map_set (&s->irc_buffer_map, user->nickname, buffer); + + buffer_add (s->ctx, buffer); + return buffer; +} + +static void +irc_get_channel_user_prefix (struct server *s, + struct channel_user *channel_user, struct str *output) +{ + if (s->ctx->show_all_prefixes) + str_append (output, channel_user->prefixes); + else if (channel_user->prefixes[0]) + str_append_c (output, channel_user->prefixes[0]); +} + +static bool +irc_channel_is_joined (struct channel *channel) +{ + // TODO: find a better way of checking if we're on a channel + return !!channel->users_len; +} + +// Note that this eats the user reference +static void +irc_channel_link_user (struct channel *channel, struct user *user, + const char *prefixes) +{ + struct user_channel *user_channel = user_channel_new (channel); + LIST_PREPEND (user->channels, user_channel); + + struct channel_user *channel_user = channel_user_new (user, prefixes); + LIST_PREPEND (channel->users, channel_user); + channel->users_len++; +} + +static void +irc_channel_unlink_user + (struct channel *channel, struct channel_user *channel_user) +{ + // First destroy the user's weak references to the channel + struct user *user = channel_user->user; + LIST_FOR_EACH (struct user_channel, iter, user->channels) + if (iter->channel == channel) + { + LIST_UNLINK (user->channels, iter); + user_channel_destroy (iter); + } + + // TODO: poll the away status for users we don't share a channel with. + // It might or might not be worth to auto-set this on with RPL_AWAY. + if (!user->channels && user != channel->s->irc_user) + user->away = false; + + // Then just unlink the user from the channel + LIST_UNLINK (channel->users, channel_user); + channel_user_destroy (channel_user); + channel->users_len--; +} + +static void +irc_channel_on_destroy (void *object, void *user_data) +{ + struct channel *channel = object; + struct server *s = user_data; + LIST_FOR_EACH (struct channel_user, iter, channel->users) + irc_channel_unlink_user (channel, iter); + if (!s->rehashing) + str_map_set (&s->irc_channels, channel->name, NULL); +} + +static struct channel * +irc_make_channel (struct server *s, char *name) +{ + hard_assert (!str_map_find (&s->irc_channels, name)); + + struct channel *channel = channel_new (s, name); + (void) channel_weak_ref (channel, irc_channel_on_destroy, s); + str_map_set (&s->irc_channels, channel->name, channel); + return channel; +} + +static void +irc_channel_broadcast_buffer_update (const struct channel *channel) +{ + struct server *s = channel->s; + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel->name); + if (buffer) + { + relay_prepare_buffer_update (s->ctx, buffer); + relay_broadcast (s->ctx); + } +} + +static void +irc_channel_set_topic (struct channel *channel, const char *topic) +{ + cstr_set (&channel->topic, xstrdup (topic)); + irc_channel_broadcast_buffer_update (channel); +} + +static struct channel_user * +irc_channel_get_user (struct channel *channel, struct user *user) +{ + LIST_FOR_EACH (struct channel_user, iter, channel->users) + if (iter->user == user) + return iter; + return NULL; +} + +static void +irc_remove_user_from_channel (struct user *user, struct channel *channel) +{ + struct channel_user *channel_user = irc_channel_get_user (channel, user); + if (channel_user) + irc_channel_unlink_user (channel, channel_user); +} + +static void +irc_left_channel (struct channel *channel) +{ + strv_reset (&channel->names_buf); + channel->show_names_after_who = false; + + LIST_FOR_EACH (struct channel_user, iter, channel->users) + irc_channel_unlink_user (channel, iter); + + // Send empty channel modes. + irc_channel_broadcast_buffer_update (channel); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +remove_conflicting_buffer (struct server *s, struct buffer *buffer) +{ + log_server_status (s, s->buffer, + "Removed buffer #s because of casemapping conflict", buffer->name); + if (s->ctx->current_buffer == buffer) + buffer_activate (s->ctx, s->buffer); + buffer_remove (s->ctx, buffer); +} + +static void +irc_try_readd_user (struct server *s, + struct user *user, struct buffer *buffer) +{ + if (str_map_find (&s->irc_users, user->nickname)) + { + // Remove user from all channels and destroy any PM buffer + user_ref (user); + LIST_FOR_EACH (struct user_channel, iter, user->channels) + irc_remove_user_from_channel (user, iter->channel); + if (buffer) + remove_conflicting_buffer (s, buffer); + user_unref (user); + } + else + { + str_map_set (&s->irc_users, user->nickname, user); + str_map_set (&s->irc_buffer_map, user->nickname, buffer); + } +} + +static void +irc_try_readd_channel (struct channel *channel, struct buffer *buffer) +{ + struct server *s = channel->s; + if (str_map_find (&s->irc_channels, channel->name)) + { + // Remove all users from channel and destroy any channel buffer + channel_ref (channel); + LIST_FOR_EACH (struct channel_user, iter, channel->users) + irc_channel_unlink_user (channel, iter); + if (buffer) + remove_conflicting_buffer (s, buffer); + channel_unref (channel); + } + else + { + str_map_set (&s->irc_channels, channel->name, channel); + str_map_set (&s->irc_buffer_map, channel->name, buffer); + } +} + +static void +irc_rehash_and_fix_conflicts (struct server *s) +{ + // Save the old maps and initialize new ones + struct str_map old_users = s->irc_users; + struct str_map old_channels = s->irc_channels; + struct str_map old_buffer_map = s->irc_buffer_map; + + s->irc_users = str_map_make (NULL); + s->irc_channels = str_map_make (NULL); + s->irc_buffer_map = str_map_make (NULL); + + s->irc_users .key_xfrm = s->irc_strxfrm; + s->irc_channels .key_xfrm = s->irc_strxfrm; + s->irc_buffer_map.key_xfrm = s->irc_strxfrm; + + // Prevent channels and users from unsetting themselves + // from server maps upon removing the last reference to them + s->rehashing = true; + + // XXX: to be perfectly sure, we should also check + // whether any users collide with channels and vice versa + + // Our own user always takes priority, add him first + if (s->irc_user) + irc_try_readd_user (s, s->irc_user, + str_map_find (&old_buffer_map, s->irc_user->nickname)); + + struct str_map_iter iter; + struct user *user; + struct channel *channel; + + iter = str_map_iter_make (&old_users); + while ((user = str_map_iter_next (&iter))) + irc_try_readd_user (s, user, + str_map_find (&old_buffer_map, user->nickname)); + + iter = str_map_iter_make (&old_channels); + while ((channel = str_map_iter_next (&iter))) + irc_try_readd_channel (channel, + str_map_find (&old_buffer_map, channel->name)); + + // Hopefully we've either moved or destroyed all the old content + s->rehashing = false; + + str_map_free (&old_users); + str_map_free (&old_channels); + str_map_free (&old_buffer_map); +} + +static void +irc_set_casemapping (struct server *s, + irc_tolower_fn tolower, irc_strxfrm_fn strxfrm) +{ + if (tolower == s->irc_tolower + && strxfrm == s->irc_strxfrm) + return; + + s->irc_tolower = tolower; + s->irc_strxfrm = strxfrm; + + // Ideally we would never have to do this but I can't think of a workaround + irc_rehash_and_fix_conflicts (s); +} + +// --- Core functionality ------------------------------------------------------ + +static bool +irc_is_connected (struct server *s) +{ + return s->state != IRC_DISCONNECTED && s->state != IRC_CONNECTING; +} + +static void +irc_update_poller (struct server *s, const struct pollfd *pfd) +{ + int new_events = s->transport->get_poll_events (s); + hard_assert (new_events != 0); + + if (!pfd || pfd->events != new_events) + poller_fd_set (&s->socket_event, new_events); +} + +static void +irc_cancel_timers (struct server *s) +{ + poller_timer_reset (&s->timeout_tmr); + poller_timer_reset (&s->ping_tmr); + poller_timer_reset (&s->reconnect_tmr); + poller_timer_reset (&s->autojoin_tmr); +} + +static void +irc_reset_connection_timeouts (struct server *s) +{ + poller_timer_set (&s->timeout_tmr, 3 * 60 * 1000); + poller_timer_set (&s->ping_tmr, (3 * 60 + 30) * 1000); + poller_timer_reset (&s->reconnect_tmr); +} + +static int64_t +irc_get_reconnect_delay (struct server *s) +{ + int64_t delay = get_config_integer (s->config, "reconnect_delay"); + int64_t delay_factor = get_config_integer + (s->ctx->config.root, "general.reconnect_delay_growing"); + for (unsigned i = 0; i < s->reconnect_attempt; i++) + { + if (delay_factor && delay > INT64_MAX / delay_factor) + break; + delay *= delay_factor; + } + + int64_t delay_max = + get_config_integer (s->ctx->config.root, "general.reconnect_delay_max"); + return MIN (delay, delay_max); +} + +static void +irc_queue_reconnect (struct server *s) +{ + // As long as the user wants us to, that is + if (!get_config_boolean (s->config, "reconnect")) + return; + + // XXX: maybe add a state for when a connect is queued? + hard_assert (s->state == IRC_DISCONNECTED); + + int64_t delay = irc_get_reconnect_delay (s); + s->reconnect_attempt++; + + log_server_status (s, s->buffer, + "Trying to reconnect in #&s seconds...", + xstrdup_printf ("%" PRId64, delay)); + poller_timer_set (&s->reconnect_tmr, delay * 1000); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void irc_process_sent_message + (const struct irc_message *msg, struct server *s); +static void irc_send (struct server *s, + const char *format, ...) ATTRIBUTE_PRINTF (2, 3); + +static void +irc_send (struct server *s, const char *format, ...) +{ + if (!soft_assert (irc_is_connected (s))) + { + log_server_debug (s, "sending a message to a dead server connection"); + return; + } + + if (s->state == IRC_CLOSING + || s->state == IRC_HALF_CLOSED) + return; + + va_list ap; + va_start (ap, format); + struct str str = str_make (); + str_append_vprintf (&str, format, ap); + va_end (ap); + + log_server_debug (s, "#a<< \"#S\"#r", ATTR_PART, str.str); + + struct irc_message msg; + irc_parse_message (&msg, str.str); + irc_process_sent_message (&msg, s); + irc_free_message (&msg); + + str_append_str (&s->write_buffer, &str); + str_free (&str); + str_append (&s->write_buffer, "\r\n"); + irc_update_poller (s, NULL); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +irc_set_state (struct server *s, enum server_state state) +{ + s->state = state; + + relay_prepare_server_update (s->ctx, s); + relay_broadcast (s->ctx); + + refresh_prompt (s->ctx); +} + +static void +irc_real_shutdown (struct server *s) +{ + hard_assert (irc_is_connected (s) && s->state != IRC_HALF_CLOSED); + + if (s->transport + && s->transport->in_before_shutdown) + s->transport->in_before_shutdown (s); + + while (shutdown (s->socket, SHUT_WR) == -1) + // XXX: we get ENOTCONN with OpenSSL (not plain) when a localhost + // server is aborted, why? strace says read 0, write 31, shutdown -1. + if (!soft_assert (errno == EINTR)) + break; + + irc_set_state (s, IRC_HALF_CLOSED); +} + +static void +irc_shutdown (struct server *s) +{ + if (s->state == IRC_CLOSING + || s->state == IRC_HALF_CLOSED) + return; + + // TODO: set a timer to cut the connection if we don't receive an EOF + irc_set_state (s, IRC_CLOSING); + + // Either there's still some data in the write buffer and we wait + // until they're sent, or we send an EOF to the server right away + if (!s->write_buffer.len) + irc_real_shutdown (s); +} + +static void +irc_destroy_connector (struct server *s) +{ + if (s->connector) + connector_free (s->connector); + free (s->connector); + s->connector = NULL; + + if (s->socks_conn) + socks_connector_free (s->socks_conn); + free (s->socks_conn); + s->socks_conn = NULL; + + // Not connecting anymore + irc_set_state (s, IRC_DISCONNECTED); +} + +static void +try_finish_quit (struct app_context *ctx) +{ + if (!ctx->quitting) + return; + + bool disconnected_all = true; + struct str_map_iter iter = str_map_iter_make (&ctx->servers); + struct server *s; + while ((s = str_map_iter_next (&iter))) + if (irc_is_connected (s)) + disconnected_all = false; + + if (disconnected_all) + ctx->polling = false; +} + +static void +irc_destroy_transport (struct server *s) +{ + if (s->transport + && s->transport->cleanup) + s->transport->cleanup (s); + s->transport = NULL; + + poller_fd_reset (&s->socket_event); + xclose (s->socket); + s->socket = -1; + irc_set_state (s, IRC_DISCONNECTED); + + str_reset (&s->read_buffer); + str_reset (&s->write_buffer); +} + +static void +irc_destroy_state (struct server *s) +{ + struct str_map_iter iter = str_map_iter_make (&s->irc_channels); + struct channel *channel; + while ((channel = str_map_iter_next (&iter))) + irc_left_channel (channel); + + if (s->irc_user) + { + user_unref (s->irc_user); + s->irc_user = NULL; + } + + str_reset (&s->irc_user_modes); + cstr_set (&s->irc_user_host, NULL); + + strv_reset (&s->outstanding_joins); + strv_reset (&s->cap_ls_buf); + s->cap_away_notify = false; + s->cap_echo_message = false; + s->cap_sasl = false; + + // Need to call this before server_init_specifics() + irc_set_casemapping (s, irc_tolower, irc_strxfrm); + + server_free_specifics (s); + server_init_specifics (s); +} + +static void +irc_disconnect (struct server *s) +{ + hard_assert (irc_is_connected (s)); + + struct str_map_iter iter = str_map_iter_make (&s->irc_buffer_map); + struct buffer *buffer; + while ((buffer = str_map_iter_next (&iter))) + log_server (s, buffer, BUFFER_LINE_UNIMPORTANT, BUFFER_LINE_STATUS, + "Disconnected from server"); + + irc_cancel_timers (s); + irc_destroy_transport (s); + irc_destroy_state (s); + + // Take any relevant actions + if (s->ctx->quitting) + try_finish_quit (s->ctx); + else if (s->manual_disconnect) + s->manual_disconnect = false; + else + { + s->reconnect_attempt = 0; + irc_queue_reconnect (s); + } +} + +static void +irc_initiate_disconnect (struct server *s, const char *reason) +{ + hard_assert (irc_is_connected (s)); + + // It can take a very long time for sending QUIT to take effect + if (s->manual_disconnect) + { + log_server_error (s, s->buffer, "#s: #s", "Disconnected from server", + "connection torn down early per user request"); + irc_disconnect (s); + return; + } + + if (reason) + irc_send (s, "QUIT :%s", reason); + else + // TODO: make the default QUIT message customizable + // -> global/per server/both? + // -> implement it with an output hook in a plugin? + irc_send (s, "QUIT :%s", PROGRAM_NAME " " PROGRAM_VERSION); + + s->manual_disconnect = true; + irc_shutdown (s); +} + +static void +request_quit (struct app_context *ctx, const char *message) +{ + if (!ctx->quitting) + { + log_global_status (ctx, "Shutting down"); + ctx->quitting = true; + + // Disable the user interface + CALL (ctx->input, hide); + } + + struct str_map_iter iter = str_map_iter_make (&ctx->servers); + struct server *s; + while ((s = str_map_iter_next (&iter))) + { + // There may be a timer set to reconnect to the server + poller_timer_reset (&s->reconnect_tmr); + + if (irc_is_connected (s)) + irc_initiate_disconnect (s, message); + else if (s->state == IRC_CONNECTING) + irc_destroy_connector (s); + } + + try_finish_quit (ctx); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +on_irc_ping_timeout (void *user_data) +{ + struct server *s = user_data; + log_server_error (s, s->buffer, + "#s: #s", "Disconnected from server", "timeout"); + irc_disconnect (s); +} + +static void +on_irc_timeout (void *user_data) +{ + // Provoke a response from the server + struct server *s = user_data; + irc_send (s, "PING :%" PRIi64, (int64_t) time (NULL)); +} + +static void +on_irc_autojoin_timeout (void *user_data) +{ + struct server *s = user_data; + + // Since we may not have information from RPL_ISUPPORT yet, + // it's our safest bet to send the channels one at a time + + struct str_map joins_sent = str_map_make (NULL); + + // We don't know the casemapping yet either, however ASCII should do + joins_sent.key_xfrm = tolower_ascii_strxfrm; + + // First join autojoin channels in their given order + const char *autojoin = get_config_string (s->config, "autojoin"); + if (autojoin) + { + struct strv v = strv_make (); + cstr_split (autojoin, ",", true, &v); + for (size_t i = 0; i < v.len; i++) + { + irc_send (s, "JOIN %s", v.vector[i]); + str_map_set (&joins_sent, v.vector[i], (void *) 1); + } + strv_free (&v); + } + + // Then also rejoin any channels from the last disconnect + struct str_map_iter iter = str_map_iter_make (&s->irc_channels); + struct channel *channel; + while ((channel = str_map_iter_next (&iter))) + { + struct str target = str_make (); + str_append (&target, channel->name); + + const char *key; + if ((key = str_map_find (&channel->param_modes, "k"))) + str_append_printf (&target, " %s", key); + + // When a channel is both autojoined and rejoined, both keys are tried + if (!channel->left_manually + && !str_map_find (&joins_sent, target.str)) + irc_send (s, "JOIN %s", target.str); + str_free (&target); + } + + str_map_free (&joins_sent); +} + +// --- Server I/O -------------------------------------------------------------- + +static char * +irc_process_hooks (struct server *s, char *input) +{ + log_server_debug (s, "#a>> \"#S\"#r", ATTR_JOIN, input); + uint64_t hash = siphash_wrapper (input, strlen (input)); + LIST_FOR_EACH (struct hook, iter, s->ctx->irc_hooks) + { + struct irc_hook *hook = (struct irc_hook *) iter; + if (!(input = hook->filter (hook, s, input))) + { + log_server_debug (s, "#a>= #s#r", ATTR_JOIN, "thrown away by hook"); + return NULL; + } + + // The old input may get freed, so we compare against a hash of it + uint64_t new_hash = siphash_wrapper (input, strlen (input)); + if (new_hash != hash) + log_server_debug (s, "#a>= \"#S\"#r", ATTR_JOIN, input); + hash = new_hash; + } + return input; +} + +static void irc_process_message + (const struct irc_message *msg, struct server *s); + +static void +irc_process_buffer_custom (struct server *s, struct str *buf) +{ + const char *start = buf->str, *end = start + buf->len; + for (const char *p = start; p + 1 < end; p++) + { + // Split the input on newlines + if (p[0] != '\r' || p[1] != '\n') + continue; + + char *processed = irc_process_hooks (s, xstrndup (start, p - start)); + start = p + 2; + if (!processed) + continue; + + struct irc_message msg; + irc_parse_message (&msg, processed); + irc_process_message (&msg, s); + irc_free_message (&msg); + + free (processed); + } + str_remove_slice (buf, 0, start - buf->str); +} + +static enum socket_io_result +irc_try_read (struct server *s) +{ + enum socket_io_result result = s->transport->try_read (s); + if (s->read_buffer.len >= (1 << 20)) + { + // XXX: this is stupid; if anything, count it in dependence of time; + // we could make transport_tls_try_read() limit the immediate amount + // of data read like socket_io_try_read() does and remove this check + log_server_error (s, s->buffer, + "The IRC server seems to spew out data frantically"); + return SOCKET_IO_ERROR; + } + if (s->read_buffer.len) + irc_process_buffer_custom (s, &s->read_buffer); + return result; +} + +static enum socket_io_result +irc_try_write (struct server *s) +{ + enum socket_io_result result = s->transport->try_write (s); + if (result == SOCKET_IO_OK) + { + // If we're flushing the write buffer and our job is complete, we send + // an EOF to the server, changing the state to IRC_HALF_CLOSED + if (s->state == IRC_CLOSING && !s->write_buffer.len) + irc_real_shutdown (s); + } + return result; +} + +static bool +irc_try_read_write (struct server *s) +{ + enum socket_io_result read_result; + enum socket_io_result write_result; + if ((read_result = irc_try_read (s)) == SOCKET_IO_ERROR + || (write_result = irc_try_write (s)) == SOCKET_IO_ERROR) + { + log_server_error (s, s->buffer, "Server connection failed"); + return false; + } + + // FIXME: this may probably fire multiple times when we're flushing, + // we should probably store a flag next to the state + if (read_result == SOCKET_IO_EOF + || write_result == SOCKET_IO_EOF) + log_server_error (s, s->buffer, "Server closed the connection"); + + // If the write needs to read and we receive an EOF, we can't flush + if (write_result == SOCKET_IO_EOF) + return false; + + if (read_result == SOCKET_IO_EOF) + { + // Eventually initiate shutdown to flush the write buffer + irc_shutdown (s); + + // If there's nothing to write, we can disconnect now + if (s->state == IRC_HALF_CLOSED) + return false; + } + return true; +} + +static void +on_irc_ready (const struct pollfd *pfd, struct server *s) +{ + if (irc_try_read_write (s)) + { + // XXX: shouldn't we rather wait for PONG messages? + irc_reset_connection_timeouts (s); + irc_update_poller (s, pfd); + } + else + // We don't want to keep the socket anymore + irc_disconnect (s); +} + +// --- Plain transport --------------------------------------------------------- + +static enum socket_io_result +transport_plain_try_read (struct server *s) +{ + enum socket_io_result result = + socket_io_try_read (s->socket, &s->read_buffer); + if (result == SOCKET_IO_ERROR) + print_debug ("%s: %s", __func__, strerror (errno)); + return result; +} + +static enum socket_io_result +transport_plain_try_write (struct server *s) +{ + enum socket_io_result result = + socket_io_try_write (s->socket, &s->write_buffer); + if (result == SOCKET_IO_ERROR) + print_debug ("%s: %s", __func__, strerror (errno)); + return result; +} + +static int +transport_plain_get_poll_events (struct server *s) +{ + int events = POLLIN; + if (s->write_buffer.len) + events |= POLLOUT; + return events; +} + +static struct transport g_transport_plain = +{ + .try_read = transport_plain_try_read, + .try_write = transport_plain_try_write, + .get_poll_events = transport_plain_get_poll_events, +}; + +// --- TLS transport ----------------------------------------------------------- + +struct transport_tls_data +{ + SSL_CTX *ssl_ctx; ///< SSL context + SSL *ssl; ///< SSL connection + bool ssl_rx_want_tx; ///< SSL_read() wants to write + bool ssl_tx_want_rx; ///< SSL_write() wants to read +}; + +/// The index in SSL_CTX user data for a reference to the server +static int g_transport_tls_data_index = -1; + +static int +transport_tls_verify_callback (int preverify_ok, X509_STORE_CTX *ctx) +{ + SSL *ssl = X509_STORE_CTX_get_ex_data + (ctx, SSL_get_ex_data_X509_STORE_CTX_idx ()); + struct server *s = SSL_CTX_get_ex_data + (SSL_get_SSL_CTX (ssl), g_transport_tls_data_index); + + X509 *cert = X509_STORE_CTX_get_current_cert (ctx); + char *subject = X509_NAME_oneline (X509_get_subject_name (cert), NULL, 0); + char *issuer = X509_NAME_oneline (X509_get_issuer_name (cert), NULL, 0); + + log_server_status (s, s->buffer, "Certificate subject: #s", subject); + log_server_status (s, s->buffer, "Certificate issuer: #s", issuer); + + if (!preverify_ok) + { + log_server_error (s, s->buffer, + "Certificate verification failed: #s", + X509_verify_cert_error_string (X509_STORE_CTX_get_error (ctx))); + } + + free (subject); + free (issuer); + return preverify_ok; +} + +static bool +transport_tls_init_ca_set (SSL_CTX *ssl_ctx, const char *file, const char *path, + struct error **e) +{ + ERR_clear_error (); + + if (file || path) + { + if (SSL_CTX_load_verify_locations (ssl_ctx, file, path)) + return true; + + return error_set (e, "%s: %s", + "Failed to set locations for the CA certificate bundle", + xerr_describe_error ()); + } + + if (!SSL_CTX_set_default_verify_paths (ssl_ctx)) + return error_set (e, "%s: %s", + "Couldn't load the default CA certificate bundle", + xerr_describe_error ()); + return true; +} + +static bool +transport_tls_init_ca (struct server *s, SSL_CTX *ssl_ctx, struct error **e) +{ + const char *ca_file = get_config_string (s->config, "tls_ca_file"); + const char *ca_path = get_config_string (s->config, "tls_ca_path"); + + char *full_ca_file = ca_file + ? resolve_filename (ca_file, resolve_relative_config_filename) : NULL; + char *full_ca_path = ca_path + ? resolve_filename (ca_path, resolve_relative_config_filename) : NULL; + + bool ok = false; + if (ca_file && !full_ca_file) + error_set (e, "Couldn't find the CA bundle file"); + else if (ca_path && !full_ca_path) + error_set (e, "Couldn't find the CA bundle path"); + else + ok = transport_tls_init_ca_set (ssl_ctx, full_ca_file, full_ca_path, e); + + free (full_ca_file); + free (full_ca_path); + return ok; +} + +static bool +transport_tls_init_ctx (struct server *s, SSL_CTX *ssl_ctx, struct error **e) +{ + bool verify = get_config_boolean (s->config, "tls_verify"); + SSL_CTX_set_verify (ssl_ctx, verify ? SSL_VERIFY_PEER : SSL_VERIFY_NONE, + transport_tls_verify_callback); + + if (g_transport_tls_data_index == -1) + g_transport_tls_data_index = + SSL_CTX_get_ex_new_index (0, "server", NULL, NULL, NULL); + SSL_CTX_set_ex_data (ssl_ctx, g_transport_tls_data_index, s); + + const char *ciphers = get_config_string (s->config, "tls_ciphers"); + if (ciphers && !SSL_CTX_set_cipher_list (ssl_ctx, ciphers)) + log_server_error (s, s->buffer, + "Failed to select any cipher from the cipher list"); + SSL_CTX_set_mode (ssl_ctx, + SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + + // Disable deprecated protocols (see RFC 7568) + SSL_CTX_set_options (ssl_ctx, SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3); + + // This seems to consume considerable amounts of memory while not giving + // that much in return; in addition to that, I'm not sure about security + // (see RFC 7525, section 3.3) +#ifdef SSL_OP_NO_COMPRESSION + SSL_CTX_set_options (ssl_ctx, SSL_OP_NO_COMPRESSION); +#endif // SSL_OP_NO_COMPRESSION +#ifdef LOMEM + SSL_CTX_set_mode (ssl_ctx, SSL_MODE_RELEASE_BUFFERS); +#endif // LOMEM + + struct error *error = NULL; + if (!transport_tls_init_ca (s, ssl_ctx, &error)) + { + if (verify) + { + error_propagate (e, error); + return false; + } + + // Just inform the user if we're not actually verifying + log_server_error (s, s->buffer, "#s", error->message); + error_free (error); + } + return true; +} + +static bool +transport_tls_init_cert (struct server *s, SSL *ssl, struct error **e) +{ + const char *tls_cert = get_config_string (s->config, "tls_cert"); + if (!tls_cert) + return true; + + ERR_clear_error (); + + bool result = false; + char *path = resolve_filename (tls_cert, resolve_relative_config_filename); + if (!path) + error_set (e, "%s: %s", "Cannot open file", tls_cert); + // XXX: perhaps we should read the file ourselves for better messages + else if (!SSL_use_certificate_file (ssl, path, SSL_FILETYPE_PEM) + || !SSL_use_PrivateKey_file (ssl, path, SSL_FILETYPE_PEM)) + error_set (e, "%s: %s", "Setting the TLS client certificate failed", + xerr_describe_error ()); + else + result = true; + free (path); + return result; +} + +static bool +transport_tls_init (struct server *s, const char *hostname, struct error **e) +{ + ERR_clear_error (); + + struct error *error = NULL; + SSL_CTX *ssl_ctx = SSL_CTX_new (SSLv23_client_method ()); + if (!ssl_ctx) + goto error_ssl_1; + if (!transport_tls_init_ctx (s, ssl_ctx, &error)) + goto error_ssl_2; + + SSL *ssl = SSL_new (ssl_ctx); + if (!ssl) + goto error_ssl_2; + + if (!transport_tls_init_cert (s, ssl, &error)) + { + // XXX: is this a reason to abort the connection? + log_server_error (s, s->buffer, "#s", error->message); + error_free (error); + error = NULL; + } + + SSL_set_connect_state (ssl); + if (!SSL_set_fd (ssl, s->socket)) + goto error_ssl_3; + + // Enable SNI, FWIW; literal IP addresses aren't allowed + struct in6_addr dummy; + if (!inet_pton (AF_INET, hostname, &dummy) + && !inet_pton (AF_INET6, hostname, &dummy)) + SSL_set_tlsext_host_name (ssl, hostname); + + struct transport_tls_data *data = xcalloc (1, sizeof *data); + data->ssl_ctx = ssl_ctx; + data->ssl = ssl; + + // Forces a handshake even if neither side wants to transmit data + data->ssl_rx_want_tx = true; + + s->transport_data = data; + return true; + +error_ssl_3: + SSL_free (ssl); +error_ssl_2: + SSL_CTX_free (ssl_ctx); +error_ssl_1: + if (!error) + error_set (&error, "%s: %s", "Could not initialize TLS", + xerr_describe_error ()); + + error_propagate (e, error); + return false; +} + +static void +transport_tls_cleanup (struct server *s) +{ + struct transport_tls_data *data = s->transport_data; + if (data->ssl) + SSL_free (data->ssl); + if (data->ssl_ctx) + SSL_CTX_free (data->ssl_ctx); + free (data); +} + +static enum socket_io_result +transport_tls_try_read (struct server *s) +{ + struct transport_tls_data *data = s->transport_data; + if (data->ssl_tx_want_rx) + return SOCKET_IO_OK; + + struct str *buf = &s->read_buffer; + data->ssl_rx_want_tx = false; + while (true) + { + ERR_clear_error (); + str_reserve (buf, 512); + int n_read = SSL_read (data->ssl, buf->str + buf->len, + buf->alloc - buf->len - 1 /* null byte */); + + const char *error_info = NULL; + switch (xssl_get_error (data->ssl, n_read, &error_info)) + { + case SSL_ERROR_NONE: + buf->str[buf->len += n_read] = '\0'; + continue; + case SSL_ERROR_ZERO_RETURN: + return SOCKET_IO_EOF; + case SSL_ERROR_WANT_READ: + return SOCKET_IO_OK; + case SSL_ERROR_WANT_WRITE: + data->ssl_rx_want_tx = true; + return SOCKET_IO_OK; + case XSSL_ERROR_TRY_AGAIN: + continue; + default: + LOG_FUNC_FAILURE ("SSL_read", error_info); + return SOCKET_IO_ERROR; + } + } +} + +static enum socket_io_result +transport_tls_try_write (struct server *s) +{ + struct transport_tls_data *data = s->transport_data; + if (data->ssl_rx_want_tx) + return SOCKET_IO_OK; + + struct str *buf = &s->write_buffer; + data->ssl_tx_want_rx = false; + while (buf->len) + { + ERR_clear_error (); + int n_written = SSL_write (data->ssl, buf->str, buf->len); + + const char *error_info = NULL; + switch (xssl_get_error (data->ssl, n_written, &error_info)) + { + case SSL_ERROR_NONE: + str_remove_slice (buf, 0, n_written); + continue; + case SSL_ERROR_ZERO_RETURN: + return SOCKET_IO_EOF; + case SSL_ERROR_WANT_WRITE: + return SOCKET_IO_OK; + case SSL_ERROR_WANT_READ: + data->ssl_tx_want_rx = true; + return SOCKET_IO_OK; + case XSSL_ERROR_TRY_AGAIN: + continue; + default: + LOG_FUNC_FAILURE ("SSL_write", error_info); + return SOCKET_IO_ERROR; + } + } + return SOCKET_IO_OK; +} + +static int +transport_tls_get_poll_events (struct server *s) +{ + struct transport_tls_data *data = s->transport_data; + + int events = POLLIN; + if (s->write_buffer.len || data->ssl_rx_want_tx) + events |= POLLOUT; + + // While we're waiting for an opposite event, we ignore the original + if (data->ssl_rx_want_tx) events &= ~POLLIN; + if (data->ssl_tx_want_rx) events &= ~POLLOUT; + return events; +} + +static void +transport_tls_in_before_shutdown (struct server *s) +{ + struct transport_tls_data *data = s->transport_data; + (void) SSL_shutdown (data->ssl); +} + +static struct transport g_transport_tls = +{ + .init = transport_tls_init, + .cleanup = transport_tls_cleanup, + .try_read = transport_tls_try_read, + .try_write = transport_tls_try_write, + .get_poll_events = transport_tls_get_poll_events, + .in_before_shutdown = transport_tls_in_before_shutdown, +}; + +// --- Connection establishment ------------------------------------------------ + +static bool +irc_autofill_user_info (struct server *s, struct error **e) +{ + const char *nicks = get_config_string (s->config, "nicks"); + const char *username = get_config_string (s->config, "username"); + const char *realname = get_config_string (s->config, "realname"); + + if (nicks && *nicks && username && *username && realname) + return true; + + // Read POSIX user info and fill the configuration if needed + errno = 0; + struct passwd *pwd = getpwuid (geteuid ()); + if (!pwd) + { + return error_set (e, + "cannot retrieve user information: %s", strerror (errno)); + } + + // FIXME: set_config_strings() writes errors on its own + if (!nicks || !*nicks) + set_config_string (s->config, "nicks", pwd->pw_name); + if (!username || !*username) + set_config_string (s->config, "username", pwd->pw_name); + + // Not all systems have the GECOS field but the vast majority does + if (!realname) + { + char *gecos = pwd->pw_gecos; + + // The first comma, if any, ends the user's real name + char *comma = strchr (gecos, ','); + if (comma) + *comma = '\0'; + + set_config_string (s->config, "realname", gecos); + } + + return true; +} + +static char * +irc_fetch_next_nickname (struct server *s) +{ + struct strv v = strv_make (); + cstr_split (get_config_string (s->config, "nicks"), ",", true, &v); + + char *result = NULL; + if (s->nick_counter >= 0 && (size_t) s->nick_counter < v.len) + result = xstrdup (v.vector[s->nick_counter++]); + if ((size_t) s->nick_counter >= v.len) + // Exhausted all nicknames + s->nick_counter = -1; + + strv_free (&v); + return result; +} + +static void +irc_register (struct server *s) +{ + // Fill in user information automatically if needed + irc_autofill_user_info (s, NULL); + + const char *username = get_config_string (s->config, "username"); + const char *realname = get_config_string (s->config, "realname"); + hard_assert (username && realname); + + // Start IRCv3 capability negotiation, with up to 3.2 features; + // at worst the server will ignore this or send a harmless error message + irc_send (s, "CAP LS 302"); + + const char *password = get_config_string (s->config, "password"); + if (password) + irc_send (s, "PASS :%s", password); + + s->nick_counter = 0; + + char *nickname = irc_fetch_next_nickname (s); + if (nickname) + irc_send (s, "NICK :%s", nickname); + else + log_server_error (s, s->buffer, "No nicks present in configuration"); + free (nickname); + + // IRC servers may ignore the last argument if it's empty + irc_send (s, "USER %s 8 * :%s", username, *realname ? realname : " "); +} + +static void +irc_finish_connection (struct server *s, int socket, const char *hostname) +{ + struct app_context *ctx = s->ctx; + + // Most of our output comes from the user one full command at a time and we + // use output buffering, so it makes a lot of sense to avoid these delays + int yes = 1; + soft_assert (setsockopt (socket, IPPROTO_TCP, TCP_NODELAY, + &yes, sizeof yes) != -1); + + set_blocking (socket, false); + s->socket = socket; + s->transport = get_config_boolean (s->config, "tls") + ? &g_transport_tls + : &g_transport_plain; + + struct error *e = NULL; + if (s->transport->init && !s->transport->init (s, hostname, &e)) + { + log_server_error (s, s->buffer, "Connection failed: #s", e->message); + error_free (e); + + xclose (s->socket); + s->socket = -1; + + s->transport = NULL; + return; + } + + log_server_status (s, s->buffer, "Connection established"); + irc_set_state (s, IRC_CONNECTED); + + s->socket_event = poller_fd_make (&ctx->poller, s->socket); + s->socket_event.dispatcher = (poller_fd_fn) on_irc_ready; + s->socket_event.user_data = s; + + irc_update_poller (s, NULL); + irc_reset_connection_timeouts (s); + irc_register (s); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +irc_on_connector_connecting (void *user_data, const char *address) +{ + struct server *s = user_data; + log_server_status (s, s->buffer, "Connecting to #s...", address); +} + +static void +irc_on_connector_error (void *user_data, const char *error) +{ + struct server *s = user_data; + log_server_error (s, s->buffer, "Connection failed: #s", error); +} + +static void +irc_on_connector_failure (void *user_data) +{ + struct server *s = user_data; + irc_destroy_connector (s); + irc_queue_reconnect (s); +} + +static void +irc_on_connector_connected (void *user_data, int socket, const char *hostname) +{ + struct server *s = user_data; + char *hostname_copy = xstrdup (hostname); + irc_destroy_connector (s); + irc_finish_connection (s, socket, hostname_copy); + free (hostname_copy); +} + +static void +irc_setup_connector (struct server *s, const struct strv *addresses) +{ + struct connector *connector = xmalloc (sizeof *connector); + connector_init (connector, &s->ctx->poller); + s->connector = connector; + + connector->user_data = s; + connector->on_connecting = irc_on_connector_connecting; + connector->on_error = irc_on_connector_error; + connector->on_connected = irc_on_connector_connected; + connector->on_failure = irc_on_connector_failure; + + for (size_t i = 0; i < addresses->len; i++) + { + const char *port = "6667", + *host = tokenize_host_port (addresses->vector[i], &port); + connector_add_target (connector, host, port); + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// TODO: see if we can further merge code for the two connectors, for example +// by making SOCKS 4A and 5 mere plugins for the connector, or by using +// a virtual interface common to them both (seems more likely) + +static void +irc_on_socks_connecting (void *user_data, + const char *address, const char *via, const char *version) +{ + struct server *s = user_data; + log_server_status (s, s->buffer, + "Connecting to #s via #s (#s)...", address, via, version); +} + +static bool +irc_setup_connector_socks (struct server *s, const struct strv *addresses, + struct error **e) +{ + const char *socks_host = get_config_string (s->config, "socks_host"); + int64_t socks_port_int = get_config_integer (s->config, "socks_port"); + + if (!socks_host) + return false; + + struct socks_connector *connector = xmalloc (sizeof *connector); + socks_connector_init (connector, &s->ctx->poller); + s->socks_conn = connector; + + connector->user_data = s; + connector->on_connecting = irc_on_socks_connecting; + connector->on_error = irc_on_connector_error; + connector->on_connected = irc_on_connector_connected; + connector->on_failure = irc_on_connector_failure; + + for (size_t i = 0; i < addresses->len; i++) + { + const char *port = "6667", + *host = tokenize_host_port (addresses->vector[i], &port); + if (!socks_connector_add_target (connector, host, port, e)) + return false; + } + + char *service = xstrdup_printf ("%" PRIi64, socks_port_int); + socks_connector_run (connector, socks_host, service, + get_config_string (s->config, "socks_username"), + get_config_string (s->config, "socks_password")); + free (service); + + // The SOCKS connector can have already failed; we mustn't return true then + if (!s->socks_conn) + return error_set (e, "SOCKS connection failed"); + return true; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +irc_initiate_connect (struct server *s) +{ + hard_assert (s->state == IRC_DISCONNECTED); + + const char *addresses = get_config_string (s->config, "addresses"); + if (!addresses || !addresses[strspn (addresses, ",")]) + { + // No sense in trying to reconnect + log_server_error (s, s->buffer, + "No addresses specified in configuration"); + return; + } + + struct strv servers = strv_make (); + cstr_split (addresses, ",", true, &servers); + + struct error *e = NULL; + if (!irc_setup_connector_socks (s, &servers, &e) && !e) + irc_setup_connector (s, &servers); + + strv_free (&servers); + + if (e) + { + irc_destroy_connector (s); + + log_server_error (s, s->buffer, "#s", e->message); + error_free (e); + irc_queue_reconnect (s); + } + else if (s->state != IRC_CONNECTED) + irc_set_state (s, IRC_CONNECTING); +} + +// --- Input prompt ------------------------------------------------------------ + +static void +make_unseen_prefix (struct app_context *ctx, struct str *active_buffers) +{ + size_t buffer_no = 0; + LIST_FOR_EACH (struct buffer, iter, ctx->buffers) + { + buffer_no++; + if (!(iter->new_messages_count - iter->new_unimportant_count) + || iter == ctx->current_buffer) + continue; + + if (active_buffers->len) + str_append_c (active_buffers, ','); + if (iter->highlighted) + str_append_c (active_buffers, '!'); + str_append_printf (active_buffers, "%zu", buffer_no); + } +} + +static void +make_chanmode_postfix (struct channel *channel, struct str *modes) +{ + if (channel->no_param_modes.len) + str_append (modes, channel->no_param_modes.str); + + struct str_map_iter iter = str_map_iter_make (&channel->param_modes); + const char *param; + while ((param = str_map_iter_next (&iter))) + str_append_c (modes, iter.link->key[0]); +} + +static void +make_server_postfix_registered (struct buffer *buffer, struct str *output) +{ + struct server *s = buffer->server; + if (buffer->type == BUFFER_CHANNEL) + { + struct channel_user *channel_user = + irc_channel_get_user (buffer->channel, s->irc_user); + if (channel_user) + irc_get_channel_user_prefix (s, channel_user, output); + } + str_append (output, s->irc_user->nickname); + if (s->irc_user_modes.len) + str_append_printf (output, "(%s)", s->irc_user_modes.str); +} + +static void +make_server_postfix (struct buffer *buffer, struct str *output) +{ + struct server *s = buffer->server; + str_append_c (output, ' '); + if (!irc_is_connected (s)) + str_append (output, "(disconnected)"); + else if (s->state != IRC_REGISTERED) + str_append (output, "(unregistered)"); + else + make_server_postfix_registered (buffer, output); +} + +static void +make_prompt (struct app_context *ctx, struct str *output) +{ + LIST_FOR_EACH (struct hook, iter, ctx->prompt_hooks) + { + struct prompt_hook *hook = (struct prompt_hook *) iter; + char *made = hook->make (hook); + if (made) + { + str_append (output, made); + free (made); + return; + } + } + + struct buffer *buffer = ctx->current_buffer; + if (!buffer) + return; + + str_append_c (output, '['); + + struct str active_buffers = str_make (); + make_unseen_prefix (ctx, &active_buffers); + if (active_buffers.len) + str_append_printf (output, "(%s) ", active_buffers.str); + str_free (&active_buffers); + + str_append_printf (output, "%d:%s", + buffer_get_index (ctx, buffer), buffer->name); + // We remember old modes, don't show them while we're not on the channel + if (buffer->type == BUFFER_CHANNEL + && irc_channel_is_joined (buffer->channel)) + { + struct str modes = str_make (); + make_chanmode_postfix (buffer->channel, &modes); + if (modes.len) + str_append_printf (output, "(+%s)", modes.str); + str_free (&modes); + + str_append_printf (output, "{%zu}", buffer->channel->users_len); + } + if (buffer->hide_unimportant) + str_append (output, "<H>"); + + if (buffer != ctx->global_buffer) + make_server_postfix (buffer, output); + + str_append_c (output, ']'); + str_append_c (output, ' '); +} + +static void +input_maybe_set_prompt (struct input *self, char *new_prompt) +{ + // Fix libedit's expectations to see a non-control character following + // the end mark (see prompt.c and literal.c) by cleaning this up + for (char *p = new_prompt; *p; ) + if (p[0] == INPUT_END_IGNORE && p[1] == INPUT_START_IGNORE) + memmove (p, p + 2, strlen (p + 2) + 1); + else + p++; + + // Redisplay can be an expensive operation + const char *prompt = CALL (self, get_prompt); + if (prompt && !strcmp (new_prompt, prompt)) + free (new_prompt); + else + CALL_ (self, set_prompt, new_prompt); +} + +static void +on_refresh_prompt (struct app_context *ctx) +{ + poller_idle_reset (&ctx->prompt_event); + bool have_attributes = !!get_attribute_printer (stdout); + + struct str prompt = str_make (); + make_prompt (ctx, &prompt); + + // libedit has a weird bug where it misapplies ignores when they're not + // followed by anything else, so let's try to move a trailing space, + // which will at least fix the default prompt. + const char *attributed_suffix = ""; +#ifdef HAVE_EDITLINE + if (have_attributes && prompt.len && prompt.str[prompt.len - 1] == ' ') + { + prompt.str[--prompt.len] = 0; + attributed_suffix = " "; + } + + // Also enable a uniform interface for prompt hooks by assuming it uses + // GNU Readline escapes: turn this into libedit's almost-flip-flop + for (size_t i = 0; i < prompt.len; i++) + if (prompt.str[i] == '\x01' || prompt.str[i] == '\x02') + prompt.str[i] = INPUT_START_IGNORE /* == INPUT_END_IGNORE */; +#endif // HAVE_EDITLINE + + char *localized = iconv_xstrdup (ctx->term_from_utf8, prompt.str, -1, NULL); + str_free (&prompt); + + // XXX: to be completely correct, we should use tputs, but we cannot + if (have_attributes) + { + char buf[16384] = ""; + FILE *memfp = fmemopen (buf, sizeof buf - 1, "wb"); + struct attr_printer state = { ctx->theme, memfp, false }; + + fputc (INPUT_START_IGNORE, memfp); + attr_printer_apply_named (&state, ATTR_PROMPT); + fputc (INPUT_END_IGNORE, memfp); + + fputs (localized, memfp); + free (localized); + + fputc (INPUT_START_IGNORE, memfp); + attr_printer_reset (&state); + fputc (INPUT_END_IGNORE, memfp); + + fputs (attributed_suffix, memfp); + + fclose (memfp); + input_maybe_set_prompt (ctx->input, xstrdup (buf)); + } + else + input_maybe_set_prompt (ctx->input, localized); +} + +// --- Helpers ----------------------------------------------------------------- + +static struct buffer * +irc_get_buffer_for_message (struct server *s, + const struct irc_message *msg, const char *target) +{ + // TODO: display such messages differently + target = irc_skip_statusmsg (s, target); + + struct buffer *buffer = str_map_find (&s->irc_buffer_map, target); + if (irc_is_channel (s, target)) + { + struct channel *channel = str_map_find (&s->irc_channels, target); + hard_assert (channel || !buffer); + + // This is weird + if (!channel) + return NULL; + } + else if (!buffer) + { + // Outgoing messages needn't have a prefix, no buffer associated + if (!msg->prefix) + return NULL; + + // Don't make user buffers for servers (they can send NOTICEs) + if (!irc_find_userhost (msg->prefix)) + return s->buffer; + + char *nickname = irc_cut_nickname (msg->prefix); + if (irc_is_this_us (s, target)) + buffer = irc_get_or_make_user_buffer (s, nickname); + free (nickname); + + // With the IRCv3.2 echo-message capability, we can receive messages + // as they are delivered to the target; in that case we return NULL + // and the caller should check the origin + } + return buffer; +} + +static bool +irc_is_highlight (struct server *s, const char *message) +{ + // This may be called by notices before even successfully registering + if (!s->irc_user) + return false; + + // Strip formatting from the message so that it doesn't interfere + // with nickname detection (colour sequences in particular) + struct formatter f = formatter_make (s->ctx, NULL); + formatter_parse_message (&f, message); + + struct str stripped = str_make (); + for (size_t i = 0; i < f.items_len; i++) + { + if (f.items[i].type == FORMATTER_ITEM_TEXT) + str_append (&stripped, f.items[i].text); + } + formatter_free (&f); + + // Well, this is rather crude but it should make most users happy. + // We could do this in proper Unicode but that's two more conversions per + // message when both the nickname and the message are likely valid UTF-8. + char *copy = str_steal (&stripped); + cstr_transform (copy, s->irc_tolower); + + char *nick = xstrdup (s->irc_user->nickname); + cstr_transform (nick, s->irc_tolower); + + // Special characters allowed in nicknames by RFC 2812: []\`_^{|} and - + // Also excluded from the ASCII: common user channel prefixes: +%@&~ + // XXX: why did I exclude those? It won't match when IRC newbies use them. + const char *delimiters = ",.;:!?()<>/=#$* \t\r\n\v\f\"'"; + + bool result = false; + char *save = NULL; + for (char *token = strtok_r (copy, delimiters, &save); + token; token = strtok_r (NULL, delimiters, &save)) + if (!strcmp (token, nick)) + { + result = true; + break; + } + + free (copy); + free (nick); + return result; +} + +static char * +irc_get_privmsg_prefix (struct server *s, struct user *user, const char *target) +{ + struct str prefix = str_make (); + if (user && irc_is_channel (s, (target = irc_skip_statusmsg (s, target)))) + { + struct channel *channel; + struct channel_user *channel_user; + if ((channel = str_map_find (&s->irc_channels, target)) + && (channel_user = irc_channel_get_user (channel, user))) + irc_get_channel_user_prefix (s, channel_user, &prefix); + } + return str_steal (&prefix); +} + +// --- Mode processor ---------------------------------------------------------- + +struct mode_processor +{ + char **params; ///< Mode string parameters + bool adding; ///< Currently adding modes + char mode_char; ///< Currently processed mode char + + // User data: + + struct server *s; ///< Server + struct channel *channel; ///< The channel being modified + unsigned changes; ///< Count of all changes + unsigned usermode_changes; ///< Count of all usermode changes +}; + +/// Process a single mode character +typedef bool (*mode_processor_apply_fn) (struct mode_processor *); + +static const char * +mode_processor_next_param (struct mode_processor *self) +{ + if (!*self->params) + return NULL; + return *self->params++; +} + +static void +mode_processor_run (struct mode_processor *self, + char **params, mode_processor_apply_fn apply_cb) +{ + self->params = params; + + const char *mode_string; + while ((mode_string = mode_processor_next_param (self))) + { + self->adding = true; + while ((self->mode_char = *mode_string++)) + { + if (self->mode_char == '+') self->adding = true; + else if (self->mode_char == '-') self->adding = false; + else if (!apply_cb (self)) + break; + } + } +} + +static int +mode_char_cmp (const void *a, const void *b) +{ + return *(const char *) a - *(const char *) b; +} + +/// Add/remove the current mode character to/from the given ordered list +static void +mode_processor_toggle (struct mode_processor *self, struct str *modes) +{ + const char *pos = strchr (modes->str, self->mode_char); + if (self->adding == !!pos) + return; + + if (self->adding) + { + str_append_c (modes, self->mode_char); + qsort (modes->str, modes->len, 1, mode_char_cmp); + } + else + str_remove_slice (modes, pos - modes->str, 1); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +mode_processor_do_user (struct mode_processor *self) +{ + const char *nickname; + struct user *user; + struct channel_user *channel_user; + if (!(nickname = mode_processor_next_param (self)) + || !(user = str_map_find (&self->s->irc_users, nickname)) + || !(channel_user = irc_channel_get_user (self->channel, user))) + return; + + // Translate mode character to user prefix character + const char *all_prefixes = self->s->irc_chanuser_prefixes; + const char *all_modes = self->s->irc_chanuser_modes; + + const char *mode = strchr (all_modes, self->mode_char); + hard_assert (mode && (size_t) (mode - all_modes) < strlen (all_prefixes)); + char prefix = all_prefixes[mode - all_modes]; + + char **prefixes = &channel_user->prefixes; + char *pos = strchr (*prefixes, prefix); + if (self->adding == !!pos) + return; + + if (self->adding) + { + // Add the new mode prefix while retaining the right order + struct str buf = str_make (); + for (const char *p = all_prefixes; *p; p++) + if (*p == prefix || strchr (*prefixes, *p)) + str_append_c (&buf, *p); + cstr_set (prefixes, str_steal (&buf)); + } + else + memmove (pos, pos + 1, strlen (pos)); +} + +static void +mode_processor_do_param_always (struct mode_processor *self) +{ + const char *param = NULL; + if (!(param = mode_processor_next_param (self))) + return; + + char key[2] = { self->mode_char, 0 }; + str_map_set (&self->channel->param_modes, key, + self->adding ? xstrdup (param) : NULL); +} + +static void +mode_processor_do_param_when_set (struct mode_processor *self) +{ + const char *param = NULL; + if (self->adding && !(param = mode_processor_next_param (self))) + return; + + char key[2] = { self->mode_char, 0 }; + str_map_set (&self->channel->param_modes, key, + self->adding ? xstrdup (param) : NULL); +} + +static bool +mode_processor_apply_channel (struct mode_processor *self) +{ + self->changes++; + if (strchr (self->s->irc_chanuser_modes, self->mode_char)) + { + self->usermode_changes++; + mode_processor_do_user (self); + } + else if (strchr (self->s->irc_chanmodes_list, self->mode_char)) + // Nothing to do here, just skip the next argument if there's any + (void) mode_processor_next_param (self); + else if (strchr (self->s->irc_chanmodes_param_always, self->mode_char)) + mode_processor_do_param_always (self); + else if (strchr (self->s->irc_chanmodes_param_when_set, self->mode_char)) + mode_processor_do_param_when_set (self); + else if (strchr (self->s->irc_chanmodes_param_never, self->mode_char)) + mode_processor_toggle (self, &self->channel->no_param_modes); + else + // It's not safe to continue, results could be undesired + return false; + return true; +} + +/// Returns whether the change has only affected channel user modes +static bool +irc_handle_mode_channel (struct channel *channel, char **params) +{ + struct mode_processor p = { .s = channel->s, .channel = channel }; + mode_processor_run (&p, params, mode_processor_apply_channel); + if (p.changes == p.usermode_changes) + return true; + + irc_channel_broadcast_buffer_update (channel); + return false; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +mode_processor_apply_user (struct mode_processor *self) +{ + mode_processor_toggle (self, &self->s->irc_user_modes); + return true; +} + +static void +irc_handle_mode_user (struct server *s, char **params) +{ + struct mode_processor p = { .s = s }; + mode_processor_run (&p, params, mode_processor_apply_user); + + relay_prepare_server_update (s->ctx, s); + relay_broadcast (s->ctx); +} + +// --- Output processing ------------------------------------------------------- + +// Both user and plugins can send whatever the heck they want to, +// we need to parse it back so that it's evident what's happening + +static void +irc_handle_sent_cap (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 1) + return; + + const char *subcommand = msg->params.vector[0]; + const char *args = (msg->params.len > 1) ? msg->params.vector[1] : ""; + if (!strcasecmp_ascii (subcommand, "REQ")) + log_server_status (s, s->buffer, + "#s: #S", "Capabilities requested", args); +} + +static void +irc_handle_sent_join (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 1) + return; + + if (strcmp (msg->params.vector[0], "0")) + cstr_split (msg->params.vector[0], ",", true, &s->outstanding_joins); +} + +static void +irc_handle_sent_notice_text (struct server *s, + const struct irc_message *msg, struct str *text) +{ + const char *target = msg->params.vector[0]; + struct buffer *buffer = irc_get_buffer_for_message (s, msg, target); + if (buffer && soft_assert (s->irc_user)) + log_outcoming_notice (s, buffer, s->irc_user->nickname, text->str); + else + log_outcoming_orphan_notice (s, target, text->str); +} + +static void +irc_handle_sent_notice (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2 || s->cap_echo_message) + return; + + // This ignores empty messages which we should not normally send + struct ctcp_chunk *chunks = ctcp_parse (msg->params.vector[1]); + LIST_FOR_EACH (struct ctcp_chunk, iter, chunks) + { + if (iter->is_extended) + log_ctcp_reply (s, msg->params.vector[0], + xstrdup_printf ("%s %s", iter->tag.str, iter->text.str)); + else + irc_handle_sent_notice_text (s, msg, &iter->text); + } + ctcp_destroy (chunks); +} + +static void +irc_handle_sent_privmsg_text (struct server *s, + const struct irc_message *msg, struct str *text, bool is_action) +{ + const char *target = msg->params.vector[0]; + struct buffer *buffer = irc_get_buffer_for_message (s, msg, target); + if (buffer && soft_assert (s->irc_user)) + { + char *prefixes = irc_get_privmsg_prefix (s, s->irc_user, target); + if (is_action) + log_outcoming_action (s, buffer, s->irc_user->nickname, text->str); + else + log_outcoming_privmsg (s, buffer, + prefixes, s->irc_user->nickname, text->str); + free (prefixes); + } + else if (is_action) + log_outcoming_orphan_action (s, target, text->str); + else + log_outcoming_orphan_privmsg (s, target, text->str); +} + +static void +irc_handle_sent_privmsg (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2 || s->cap_echo_message) + return; + + // This ignores empty messages which we should not normally send + // and the server is likely going to reject with an error reply anyway + struct ctcp_chunk *chunks = ctcp_parse (msg->params.vector[1]); + LIST_FOR_EACH (struct ctcp_chunk, iter, chunks) + { + if (!iter->is_extended) + irc_handle_sent_privmsg_text (s, msg, &iter->text, false); + else if (!strcmp (iter->tag.str, "ACTION")) + irc_handle_sent_privmsg_text (s, msg, &iter->text, true); + else + log_ctcp_query (s, msg->params.vector[0], iter->tag.str); + } + ctcp_destroy (chunks); +} + +static struct irc_handler +{ + const char *name; + void (*handler) (struct server *s, const struct irc_message *msg); +} +g_irc_sent_handlers[] = +{ + // This list needs to stay sorted + { "CAP", irc_handle_sent_cap }, + { "JOIN", irc_handle_sent_join }, + { "NOTICE", irc_handle_sent_notice }, + { "PRIVMSG", irc_handle_sent_privmsg }, +}; + +static int +irc_handler_cmp_by_name (const void *a, const void *b) +{ + const struct irc_handler *first = a; + const struct irc_handler *second = b; + return strcasecmp_ascii (first->name, second->name); +} + +static void +irc_process_sent_message (const struct irc_message *msg, struct server *s) +{ + // The server is free to reject even a matching prefix + // XXX: even though no prefix should normally be present, this is racy + if (msg->prefix && !irc_is_this_us (s, msg->prefix)) + return; + + struct irc_handler key = { .name = msg->command }; + struct irc_handler *handler = bsearch (&key, g_irc_sent_handlers, + N_ELEMENTS (g_irc_sent_handlers), sizeof key, irc_handler_cmp_by_name); + if (handler) + handler->handler (s, msg); +} + +// --- Input handling ---------------------------------------------------------- + +static void +irc_handle_authenticate (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 1) + return; + + // Empty challenge -> empty response for e.g. SASL EXTERNAL, + // abort anything else as it doesn't make much sense to let the user do it + if (!strcmp (msg->params.vector[0], "+")) + irc_send (s, "AUTHENTICATE +"); + else + irc_send (s, "AUTHENTICATE *"); +} + +static void +irc_handle_away (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix) + return; + + char *nickname = irc_cut_nickname (msg->prefix); + struct user *user = str_map_find (&s->irc_users, nickname); + free (nickname); + + // Let's allow the server to make us away + if (user) + user->away = !!msg->params.len; +} + +static void +irc_process_cap_ls (struct server *s) +{ + log_server_status (s, s->buffer, + "#s: #&S", "Capabilities supported", strv_join (&s->cap_ls_buf, " ")); + + struct strv chosen = strv_make (); + struct strv use = strv_make (); + + cstr_split (get_config_string (s->config, "capabilities"), ",", true, &use); + + // Filter server capabilities for ones we can make use of + for (size_t i = 0; i < s->cap_ls_buf.len; i++) + { + const char *cap = s->cap_ls_buf.vector[i]; + size_t cap_name_len = strcspn (cap, "="); + for (size_t k = 0; k < use.len; k++) + if (!strncasecmp_ascii (use.vector[k], cap, cap_name_len)) + strv_append_owned (&chosen, xstrndup (cap, cap_name_len)); + } + strv_reset (&s->cap_ls_buf); + + char *chosen_str = strv_join (&chosen, " "); + strv_free (&chosen); + strv_free (&use); + + // XXX: with IRCv3.2, this may end up being too long for one message, + // and we need to be careful with CAP END. One probably has to count + // the number of sent CAP REQ vs the number of received CAP ACK/NAK. + if (s->state == IRC_CONNECTED) + irc_send (s, "CAP REQ :%s", chosen_str); + + free (chosen_str); +} + +static void +irc_toggle_cap (struct server *s, const char *cap, bool active) +{ + if (!strcasecmp_ascii (cap, "echo-message")) s->cap_echo_message = active; + if (!strcasecmp_ascii (cap, "away-notify")) s->cap_away_notify = active; + if (!strcasecmp_ascii (cap, "sasl")) s->cap_sasl = active; +} + +static void +irc_try_finish_cap_negotiation (struct server *s) +{ + // It does not make sense to do this post-registration, although it would + // not hurt either, as the server must ignore it in that case + if (s->state == IRC_CONNECTED) + irc_send (s, "CAP END"); +} + +static void +irc_handle_cap (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2) + return; + + struct strv v = strv_make (); + const char *args = ""; + if (msg->params.len > 2) + cstr_split ((args = msg->params.vector[2]), " ", true, &v); + + const char *subcommand = msg->params.vector[1]; + if (!strcasecmp_ascii (subcommand, "ACK")) + { + log_server_status (s, s->buffer, + "#s: #S", "Capabilities acknowledged", args); + for (size_t i = 0; i < v.len; i++) + { + const char *cap = v.vector[i]; + bool active = true; + if (*cap == '-') + { + active = false; + cap++; + } + irc_toggle_cap (s, cap, active); + } + if (s->cap_sasl && s->transport == &g_transport_tls) + irc_send (s, "AUTHENTICATE EXTERNAL"); + else + irc_try_finish_cap_negotiation (s); + } + else if (!strcasecmp_ascii (subcommand, "NAK")) + { + log_server_error (s, s->buffer, + "#s: #S", "Capabilities not acknowledged", args); + irc_try_finish_cap_negotiation (s); + } + else if (!strcasecmp_ascii (subcommand, "DEL")) + { + log_server_error (s, s->buffer, + "#s: #S", "Capabilities deleted", args); + for (size_t i = 0; i < v.len; i++) + irc_toggle_cap (s, v.vector[i], false); + } + else if (!strcasecmp_ascii (subcommand, "LS")) + { + + if (msg->params.len > 3 && !strcmp (args, "*")) + cstr_split (msg->params.vector[3], " ", true, &s->cap_ls_buf); + else + { + strv_append_vector (&s->cap_ls_buf, v.vector); + irc_process_cap_ls (s); + } + } + + strv_free (&v); +} + +static void +irc_handle_chghost (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 2) + return; + + char *nickname = irc_cut_nickname (msg->prefix); + struct user *user = str_map_find (&s->irc_users, nickname); + free (nickname); + if (!user) + return; + + char *new_prefix = xstrdup_printf ("%s!%s@%s", user->nickname, + msg->params.vector[0], msg->params.vector[1]); + + if (irc_is_this_us (s, msg->prefix)) + { + cstr_set (&s->irc_user_host, xstrdup_printf ("%s@%s", + msg->params.vector[0], msg->params.vector[1])); + + log_chghost_self (s, s->buffer, new_prefix); + + // Log a message in all open buffers on this server + struct str_map_iter iter = str_map_iter_make (&s->irc_buffer_map); + struct buffer *buffer; + while ((buffer = str_map_iter_next (&iter))) + log_chghost_self (s, buffer, new_prefix); + } + else + { + // Log a message in any PM buffer + struct buffer *buffer = + str_map_find (&s->irc_buffer_map, user->nickname); + if (buffer) + log_chghost (s, buffer, msg->prefix, new_prefix); + + // Log a message in all channels the user is in + LIST_FOR_EACH (struct user_channel, iter, user->channels) + { + buffer = str_map_find (&s->irc_buffer_map, iter->channel->name); + hard_assert (buffer != NULL); + log_chghost (s, buffer, msg->prefix, new_prefix); + } + } + + free (new_prefix); +} + +static void +irc_handle_error (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 1) + return; + + log_server_error (s, s->buffer, "#m", msg->params.vector[0]); +} + +static void +irc_handle_invite (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 2) + return; + + const char *target = msg->params.vector[0]; + const char *channel_name = msg->params.vector[1]; + + struct buffer *buffer; + if (!(buffer = str_map_find (&s->irc_buffer_map, channel_name))) + buffer = s->buffer; + + // IRCv3.2 invite-notify extension allows the target to be someone else + if (irc_is_this_us (s, target)) + log_server_status (s, buffer, + "#n has invited you to #S", msg->prefix, channel_name); + else + log_server_status (s, buffer, + "#n has invited #n to #S", msg->prefix, target, channel_name); +} + +static bool +irc_satisfy_join (struct server *s, const char *target) +{ + // This queue could use some garbage collection, + // but it's unlikely to pose problems. + for (size_t i = 0; i < s->outstanding_joins.len; i++) + if (!irc_server_strcmp (s, target, s->outstanding_joins.vector[i])) + { + strv_remove (&s->outstanding_joins, i); + return true; + } + return false; +} + +static void +irc_handle_join (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 1) + return; + + // TODO: RFC 2812 doesn't guarantee that the argument isn't a target list. + const char *channel_name = msg->params.vector[0]; + if (!irc_is_channel (s, channel_name)) + return; + + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel_name); + hard_assert (channel || !buffer); + + // We've joined a new channel + if (!channel) + { + // This is weird, ignoring + if (!irc_is_this_us (s, msg->prefix)) + return; + + buffer = buffer_new (s->ctx->input, + BUFFER_CHANNEL, irc_make_buffer_name (s, channel_name)); + buffer->server = s; + buffer->channel = channel = + irc_make_channel (s, xstrdup (channel_name)); + str_map_set (&s->irc_buffer_map, channel->name, buffer); + + buffer_add (s->ctx, buffer); + + char *input = CALL_ (s->ctx->input, get_line, NULL); + if (irc_satisfy_join (s, channel_name) && !*input) + buffer_activate (s->ctx, buffer); + else + buffer->highlighted = true; + free (input); + } + + if (irc_is_this_us (s, msg->prefix)) + { + // Reset the field so that we rejoin the channel after reconnecting + channel->left_manually = false; + + // Request the channel mode as we don't get it automatically + str_reset (&channel->no_param_modes); + str_map_clear (&channel->param_modes); + irc_send (s, "MODE %s", channel_name); + + if ((channel->show_names_after_who = s->cap_away_notify)) + irc_send (s, "WHO %s", channel_name); + } + + // Add the user to the channel + char *nickname = irc_cut_nickname (msg->prefix); + irc_channel_link_user (channel, irc_get_or_make_user (s, nickname), ""); + free (nickname); + + // Finally log the message + if (buffer) + { + log_server (s, buffer, BUFFER_LINE_UNIMPORTANT, BUFFER_LINE_JOIN, + "#N #a#s#r #S", msg->prefix, ATTR_JOIN, "has joined", channel_name); + } +} + +static void +irc_handle_kick (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 2) + return; + + const char *channel_name = msg->params.vector[0]; + const char *target = msg->params.vector[1]; + if (!irc_is_channel (s, channel_name) + || irc_is_channel (s, target)) + return; + + const char *message = NULL; + if (msg->params.len > 2) + message = msg->params.vector[2]; + + struct user *user = str_map_find (&s->irc_users, target); + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel_name); + hard_assert (channel || !buffer); + + // It would be weird for this to be false + if (user && channel) + { + if (irc_is_this_us (s, target)) + irc_left_channel (channel); + else + irc_remove_user_from_channel (user, channel); + } + + if (buffer) + { + struct formatter f = formatter_make (s->ctx, s); + formatter_add (&f, "#N #a#s#r #n", + msg->prefix, ATTR_PART, "has kicked", target); + if (message) + formatter_add (&f, " (#m)", message); + log_formatter (s->ctx, buffer, 0, BUFFER_LINE_PART, &f); + } +} + +static void +irc_handle_kill (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 2) + return; + + const char *target = msg->params.vector[0]; + const char *comment = msg->params.vector[1]; + + if (irc_is_this_us (s, target)) + log_server_status (s, s->buffer, + "You've been killed by #n (#m)", msg->prefix, comment); +} + +static void +irc_handle_mode (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 1) + return; + + const char *context = msg->params.vector[0]; + + // Join the modes back to a single string + struct strv copy = strv_make (); + strv_append_vector (©, msg->params.vector + 1); + char *modes = strv_join (©, " "); + strv_free (©); + + if (irc_is_channel (s, context)) + { + struct channel *channel = str_map_find (&s->irc_channels, context); + struct buffer *buffer = str_map_find (&s->irc_buffer_map, context); + hard_assert (channel || !buffer); + + int flags = 0; + if (channel + && irc_handle_mode_channel (channel, msg->params.vector + 1)) + // This is 90% automode spam, let's not let it steal attention, + // maybe this behaviour should be configurable though + flags = BUFFER_LINE_UNIMPORTANT; + + if (buffer) + { + log_server (s, buffer, flags, BUFFER_LINE_STATUS, + "Mode #S [#S] by #n", context, modes, msg->prefix); + } + } + else if (irc_is_this_us (s, context)) + { + irc_handle_mode_user (s, msg->params.vector + 1); + log_server_status (s, s->buffer, + "User mode [#S] by #n", modes, msg->prefix); + } + + free (modes); +} + +static void +irc_handle_nick (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 1) + return; + + const char *new_nickname = msg->params.vector[0]; + + char *nickname = irc_cut_nickname (msg->prefix); + struct user *user = str_map_find (&s->irc_users, nickname); + free (nickname); + if (!user) + return; + + bool lexicographically_different = + !!irc_server_strcmp (s, user->nickname, new_nickname); + + // What the fuck, someone renamed themselves to ourselves + // TODO: probably log a message and force a reconnect + if (lexicographically_different + && !irc_server_strcmp (s, new_nickname, s->irc_user->nickname)) + return; + + // Log a message in any PM buffer (we may even have one for ourselves) + struct buffer *pm_buffer = + str_map_find (&s->irc_buffer_map, user->nickname); + if (pm_buffer) + { + if (irc_is_this_us (s, msg->prefix)) + log_nick_self (s, pm_buffer, new_nickname); + else + log_nick (s, pm_buffer, msg->prefix, new_nickname); + } + + // The new nickname may collide with a user referenced by a PM buffer, + // or in case of data inconsistency with the server, channels. + // In the latter case we need the colliding user to leave all of them. + struct user *user_collision = NULL; + if (lexicographically_different + && (user_collision = str_map_find (&s->irc_users, new_nickname))) + LIST_FOR_EACH (struct user_channel, iter, user_collision->channels) + irc_remove_user_from_channel (user_collision, iter->channel); + + struct buffer *buffer_collision = NULL; + if (lexicographically_different + && (buffer_collision = str_map_find (&s->irc_buffer_map, new_nickname))) + { + hard_assert (buffer_collision->type == BUFFER_PM); + hard_assert (buffer_collision->user == user_collision); + + user_unref (buffer_collision->user); + buffer_collision->user = user_ref (user); + } + + if (pm_buffer && buffer_collision) + { + // There's not much else we can do other than somehow try to merge + // one buffer into the other. In our case, the original buffer wins. + buffer_merge (s->ctx, buffer_collision, pm_buffer); + if (s->ctx->current_buffer == pm_buffer) + buffer_activate (s->ctx, buffer_collision); + buffer_remove (s->ctx, pm_buffer); + pm_buffer = buffer_collision; + } + + // The colliding user should be completely gone by now + if (lexicographically_different) + hard_assert (!str_map_find (&s->irc_users, new_nickname)); + + // Now we can rename everything to reflect the new nickname + if (pm_buffer) + { + str_map_set (&s->irc_buffer_map, user->nickname, NULL); + str_map_set (&s->irc_buffer_map, new_nickname, pm_buffer); + + char *x = irc_make_buffer_name (s, new_nickname); + buffer_rename (s->ctx, pm_buffer, x); + free (x); + } + + str_map_set (&s->irc_users, user->nickname, NULL); + str_map_set (&s->irc_users, new_nickname, user); + + cstr_set (&user->nickname, xstrdup (new_nickname)); + + // Finally broadcast the event to relay clients and secondary buffers + if (irc_is_this_us (s, new_nickname)) + { + relay_prepare_server_update (s->ctx, s); + relay_broadcast (s->ctx); + + log_nick_self (s, s->buffer, new_nickname); + + // Log a message in all open buffers on this server + struct str_map_iter iter = str_map_iter_make (&s->irc_buffer_map); + struct buffer *buffer; + while ((buffer = str_map_iter_next (&iter))) + { + // We've already done that + if (buffer != pm_buffer) + log_nick_self (s, buffer, new_nickname); + } + } + else + { + // Log a message in all channels the user is in + LIST_FOR_EACH (struct user_channel, iter, user->channels) + { + struct buffer *buffer = + str_map_find (&s->irc_buffer_map, iter->channel->name); + hard_assert (buffer != NULL); + log_nick (s, buffer, msg->prefix, new_nickname); + } + } +} + +static void +irc_handle_ctcp_reply (struct server *s, + const struct irc_message *msg, struct ctcp_chunk *chunk) +{ + const char *target = msg->params.vector[0]; + if (irc_is_this_us (s, msg->prefix)) + log_ctcp_reply (s, target, + xstrdup_printf ("%s %s", chunk->tag.str, chunk->text.str)); + else + log_server_status (s, s->buffer, "CTCP reply from #n: #S #S", + msg->prefix, chunk->tag.str, chunk->text.str); +} + +static void +irc_handle_notice_text (struct server *s, + const struct irc_message *msg, struct str *text) +{ + const char *target = msg->params.vector[0]; + struct buffer *buffer = irc_get_buffer_for_message (s, msg, target); + if (!buffer) + { + if (irc_is_this_us (s, msg->prefix)) + log_outcoming_orphan_notice (s, target, text->str); + return; + } + + char *nick = irc_cut_nickname (msg->prefix); + // IRCv3.2 echo-message could otherwise cause us to highlight ourselves + if (!irc_is_this_us (s, msg->prefix) && irc_is_highlight (s, text->str)) + log_server (s, buffer, BUFFER_LINE_HIGHLIGHT, BUFFER_LINE_STATUS, + "#a#s(#S)#r: #m", ATTR_HIGHLIGHT, "Notice", nick, text->str); + else + log_outcoming_notice (s, buffer, msg->prefix, text->str); + free (nick); +} + +static void +irc_handle_notice (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 2) + return; + + // This ignores empty messages which we should never receive anyway + struct ctcp_chunk *chunks = ctcp_parse (msg->params.vector[1]); + LIST_FOR_EACH (struct ctcp_chunk, iter, chunks) + if (!iter->is_extended) + irc_handle_notice_text (s, msg, &iter->text); + else + irc_handle_ctcp_reply (s, msg, iter); + ctcp_destroy (chunks); +} + +static void +irc_handle_part (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 1) + return; + + const char *channel_name = msg->params.vector[0]; + if (!irc_is_channel (s, channel_name)) + return; + + const char *message = NULL; + if (msg->params.len > 1) + message = msg->params.vector[1]; + + char *nickname = irc_cut_nickname (msg->prefix); + struct user *user = str_map_find (&s->irc_users, nickname); + free (nickname); + + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel_name); + hard_assert (channel || !buffer); + + // It would be weird for this to be false + if (user && channel) + { + if (irc_is_this_us (s, msg->prefix)) + irc_left_channel (channel); + else + irc_remove_user_from_channel (user, channel); + } + + if (buffer) + { + struct formatter f = formatter_make (s->ctx, s); + formatter_add (&f, "#N #a#s#r #S", + msg->prefix, ATTR_PART, "has left", channel_name); + if (message) + formatter_add (&f, " (#m)", message); + log_formatter (s->ctx, buffer, + BUFFER_LINE_UNIMPORTANT, BUFFER_LINE_PART, &f); + } +} + +static void +irc_handle_ping (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len) + irc_send (s, "PONG :%s", msg->params.vector[0]); + else + irc_send (s, "PONG"); +} + +static char * +ctime_now (char buf[26]) +{ + struct tm tm_; + time_t now = time (NULL); + if (!asctime_r (localtime_r (&now, &tm_), buf)) + return NULL; + + // Annoying thing + *strchr (buf, '\n') = '\0'; + return buf; +} + +static void irc_send_ctcp_reply (struct server *s, const char *recipient, + const char *format, ...) ATTRIBUTE_PRINTF (3, 4); + +static void +irc_send_ctcp_reply (struct server *s, + const char *recipient, const char *format, ...) +{ + struct str m = str_make (); + + va_list ap; + va_start (ap, format); + str_append_vprintf (&m, format, ap); + va_end (ap); + + irc_send (s, "NOTICE %s :\x01%s\x01", recipient, m.str); + str_free (&m); +} + +static void +irc_handle_ctcp_request (struct server *s, + const struct irc_message *msg, struct ctcp_chunk *chunk) +{ + const char *target = msg->params.vector[0]; + if (irc_is_this_us (s, msg->prefix)) + { + if (s->cap_echo_message) + log_ctcp_query (s, target, chunk->tag.str); + if (!irc_is_this_us (s, target)) + return; + } + + struct formatter f = formatter_make (s->ctx, s); + formatter_add (&f, "CTCP requested by #n", msg->prefix); + if (irc_is_channel (s, irc_skip_statusmsg (s, target))) + formatter_add (&f, " (to #S)", target); + formatter_add (&f, ": #S", chunk->tag.str); + log_formatter (s->ctx, s->buffer, 0, BUFFER_LINE_STATUS, &f); + + char *nickname = irc_cut_nickname (msg->prefix); + + if (!strcmp (chunk->tag.str, "CLIENTINFO")) + irc_send_ctcp_reply (s, nickname, "CLIENTINFO %s %s %s %s", + "PING", "VERSION", "TIME", "CLIENTINFO"); + else if (!strcmp (chunk->tag.str, "PING")) + irc_send_ctcp_reply (s, nickname, "PING %s", chunk->text.str); + else if (!strcmp (chunk->tag.str, "VERSION")) + { + struct utsname info; + if (uname (&info)) + LOG_LIBC_FAILURE ("uname"); + else + irc_send_ctcp_reply (s, nickname, "VERSION %s %s on %s %s", + PROGRAM_NAME, PROGRAM_VERSION, info.sysname, info.machine); + } + else if (!strcmp (chunk->tag.str, "TIME")) + { + char buf[26]; + if (!ctime_now (buf)) + LOG_LIBC_FAILURE ("asctime_r"); + else + irc_send_ctcp_reply (s, nickname, "TIME %s", buf); + } + + free (nickname); +} + +static void +irc_handle_privmsg_text (struct server *s, + const struct irc_message *msg, struct str *text, bool is_action) +{ + const char *target = msg->params.vector[0]; + struct buffer *buffer = irc_get_buffer_for_message (s, msg, target); + if (!buffer) + { + if (irc_is_this_us (s, msg->prefix)) + log_outcoming_orphan_privmsg (s, target, text->str); + return; + } + + char *nickname = irc_cut_nickname (msg->prefix); + char *prefixes = irc_get_privmsg_prefix + (s, str_map_find (&s->irc_users, nickname), target); + + // Make autocomplete offer recent speakers first on partial matches + // (note that freshly joined users also move to the front) + struct user *user; + struct channel_user *channel_user; + if (!irc_is_this_us (s, msg->prefix) && buffer->channel + && (user = str_map_find (&s->irc_users, nickname)) + && (channel_user = irc_channel_get_user (buffer->channel, user))) + { + LIST_UNLINK (buffer->channel->users, channel_user); + LIST_PREPEND (buffer->channel->users, channel_user); + } + + // IRCv3.2 echo-message could otherwise cause us to highlight ourselves + if (irc_is_this_us (s, msg->prefix) || !irc_is_highlight (s, text->str)) + { + if (is_action) + log_outcoming_action (s, buffer, nickname, text->str); + else + log_outcoming_privmsg (s, buffer, prefixes, nickname, text->str); + } + else if (is_action) + log_server (s, buffer, BUFFER_LINE_HIGHLIGHT, BUFFER_LINE_ACTION, + "#a#S#r #m", ATTR_HIGHLIGHT, nickname, text->str); + else + log_server (s, buffer, BUFFER_LINE_HIGHLIGHT, 0, + "#a<#S#S>#r #m", ATTR_HIGHLIGHT, prefixes, nickname, text->str); + + free (nickname); + free (prefixes); +} + +static void +irc_handle_privmsg (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 2) + return; + + // This ignores empty messages which we should never receive anyway + struct ctcp_chunk *chunks = ctcp_parse (msg->params.vector[1]); + LIST_FOR_EACH (struct ctcp_chunk, iter, chunks) + if (!iter->is_extended) + irc_handle_privmsg_text (s, msg, &iter->text, false); + else if (!strcmp (iter->tag.str, "ACTION")) + irc_handle_privmsg_text (s, msg, &iter->text, true); + else + irc_handle_ctcp_request (s, msg, iter); + ctcp_destroy (chunks); +} + +static void +log_quit (struct server *s, + struct buffer *buffer, const char *prefix, const char *reason) +{ + struct formatter f = formatter_make (s->ctx, s); + formatter_add (&f, "#N #a#s#r", prefix, ATTR_PART, "has quit"); + if (reason) + formatter_add (&f, " (#m)", reason); + log_formatter (s->ctx, buffer, + BUFFER_LINE_UNIMPORTANT, BUFFER_LINE_PART, &f); +} + +static void +irc_handle_quit (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix) + return; + + // What the fuck, the server never sends this back + if (irc_is_this_us (s, msg->prefix)) + return; + + char *nickname = irc_cut_nickname (msg->prefix); + struct user *user = str_map_find (&s->irc_users, nickname); + free (nickname); + if (!user) + return; + + const char *message = NULL; + if (msg->params.len > 0) + message = msg->params.vector[0]; + + // Log a message in any PM buffer + struct buffer *buffer = + str_map_find (&s->irc_buffer_map, user->nickname); + if (buffer) + { + log_quit (s, buffer, msg->prefix, message); + + // TODO: set some kind of a flag in the buffer and when the user + // reappears on a channel (JOIN), log a "is back online" message. + // Also set this flag when we receive a "no such nick" numeric + // and reset it when we send something to the buffer. + } + + // Log a message in all channels the user is in + LIST_FOR_EACH (struct user_channel, iter, user->channels) + { + if ((buffer = str_map_find (&s->irc_buffer_map, iter->channel->name))) + log_quit (s, buffer, msg->prefix, message); + + // This destroys "iter" which doesn't matter to us + irc_remove_user_from_channel (user, iter->channel); + } +} + +static void +irc_handle_tagmsg (struct server *s, const struct irc_message *msg) +{ + // TODO: here we can process "typing" tags, once we find this useful + (void) s; + (void) msg; +} + +static void +irc_handle_topic (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 2) + return; + + const char *channel_name = msg->params.vector[0]; + const char *topic = msg->params.vector[1]; + if (!irc_is_channel (s, channel_name)) + return; + + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel_name); + hard_assert (channel || !buffer); + + // It would be weird for this to be false + if (channel) + irc_channel_set_topic (channel, topic); + + if (buffer) + { + log_server (s, buffer, 0, BUFFER_LINE_STATUS, "#n #s \"#m\"", + msg->prefix, "has changed the topic to", topic); + } +} + +static void +irc_handle_wallops (struct server *s, const struct irc_message *msg) +{ + if (!msg->prefix || msg->params.len < 1) + return; + + const char *message = msg->params.vector[0]; + log_server (s, s->buffer, 0, 0, "<#n> #m", msg->prefix, message); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static struct irc_handler g_irc_handlers[] = +{ + // This list needs to stay sorted + { "AUTHENTICATE", irc_handle_authenticate }, + { "AWAY", irc_handle_away }, + { "CAP", irc_handle_cap }, + { "CHGHOST", irc_handle_chghost }, + { "ERROR", irc_handle_error }, + { "INVITE", irc_handle_invite }, + { "JOIN", irc_handle_join }, + { "KICK", irc_handle_kick }, + { "KILL", irc_handle_kill }, + { "MODE", irc_handle_mode }, + { "NICK", irc_handle_nick }, + { "NOTICE", irc_handle_notice }, + { "PART", irc_handle_part }, + { "PING", irc_handle_ping }, + { "PRIVMSG", irc_handle_privmsg }, + { "QUIT", irc_handle_quit }, + { "TAGMSG", irc_handle_tagmsg }, + { "TOPIC", irc_handle_topic }, + { "WALLOPS", irc_handle_wallops }, +}; + +static bool +irc_try_parse_word_for_userhost (struct server *s, const char *word) +{ + regex_t re; + int err = regcomp (&re, "^[^!@]+!([^!@]+@[^!@]+)$", REG_EXTENDED); + if (!soft_assert (!err)) + return false; + + regmatch_t matches[2]; + bool result = false; + if (!regexec (&re, word, 2, matches, 0)) + { + cstr_set (&s->irc_user_host, xstrndup (word + matches[1].rm_so, + matches[1].rm_eo - matches[1].rm_so)); + result = true; + } + regfree (&re); + return result; +} + +static void +irc_try_parse_welcome_for_userhost (struct server *s, const char *m) +{ + struct strv v = strv_make (); + cstr_split (m, " ", true, &v); + for (size_t i = 0; i < v.len; i++) + if (irc_try_parse_word_for_userhost (s, v.vector[i])) + break; + strv_free (&v); +} + +static bool process_input_line + (struct app_context *, struct buffer *, const char *, int); +static void on_autoaway_timer (struct app_context *ctx); + +static void +irc_on_registered (struct server *s, const char *nickname) +{ + s->irc_user = irc_get_or_make_user (s, nickname); + str_reset (&s->irc_user_modes); + cstr_set (&s->irc_user_host, NULL); + + irc_set_state (s, IRC_REGISTERED); + + // XXX: we can also use WHOIS if it's not supported (optional by RFC 2812) + // TODO: maybe rather always use RPL_ISUPPORT NICKLEN & USERLEN & HOSTLEN + // since we don't seem to follow any subsequent changes in userhost; + // unrealircd sends RPL_HOSTHIDDEN (396), which has an optional user part, + // and there is also CAP CHGHOST which /may/ send it to ourselves + irc_send (s, "USERHOST %s", s->irc_user->nickname); + + // A little hack that reinstates auto-away status when we get disconnected + if (s->autoaway_active) + on_autoaway_timer (s->ctx); + + const char *command = get_config_string (s->config, "command"); + if (command) + { + log_server_debug (s, "Executing \"#s\"", command); + (void) process_input_line (s->ctx, s->buffer, command, 0); + } + + int64_t command_delay = get_config_integer (s->config, "command_delay"); + log_server_debug (s, "Autojoining channels in #&s seconds...", + xstrdup_printf ("%" PRId64, command_delay)); + poller_timer_set (&s->autojoin_tmr, command_delay * 1000); +} + +static void +irc_handle_rpl_userhost (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2) + return; + + const char *response = msg->params.vector[1]; + struct strv v = strv_make (); + cstr_split (response, " ", true, &v); + + for (size_t i = 0; i < v.len; i++) + { + char *nick = v.vector[i]; + char *equals = strchr (nick, '='); + + if (!equals || equals == nick) + continue; + + // User is an IRC operator + if (equals[-1] == '*') + equals[-1] = '\0'; + else + equals[ 0] = '\0'; + + // TODO: make use of this (away status polling?) + char away_status = equals[1]; + if (!strchr ("+-", away_status)) + continue; + + char *userhost = equals + 2; + if (irc_is_this_us (s, nick)) + cstr_set (&s->irc_user_host, xstrdup (userhost)); + } + strv_free (&v); +} + +static void +irc_handle_rpl_umodeis (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2) + return; + + str_reset (&s->irc_user_modes); + irc_handle_mode_user (s, msg->params.vector + 1); + + // XXX: do we want to log a message? +} + +static void +irc_handle_rpl_namreply (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 4) + return; + + const char *channel_name = msg->params.vector[2]; + const char *nicks = msg->params.vector[3]; + + // Just push the nicknames to a string vector to process later + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + if (channel) + cstr_split (nicks, " ", true, &channel->names_buf); + else + log_server_status (s, s->buffer, "Users on #S: #S", + channel_name, nicks); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct channel_user_sort_entry +{ + struct server *s; ///< Server (because of the qsort API) + struct channel_user *channel_user; ///< Channel user +}; + +static int +channel_user_sort_entry_cmp (const void *entry_a, const void *entry_b) +{ + const struct channel_user_sort_entry *a = entry_a; + const struct channel_user_sort_entry *b = entry_b; + struct server *s = a->s; + + // First order by the most significant channel user prefix + const char *prio_a = strchr (s->irc_chanuser_prefixes, + a->channel_user->prefixes[0]); + const char *prio_b = strchr (s->irc_chanuser_prefixes, + b->channel_user->prefixes[0]); + + // Put unrecognized prefixes at the end of the list + if (prio_a || prio_b) + { + if (!prio_a) return 1; + if (!prio_b) return -1; + + if (prio_a != prio_b) + return prio_a - prio_b; + } + + return irc_server_strcmp (s, + a->channel_user->user->nickname, + b->channel_user->user->nickname); +} + +static void +irc_sort_channel_users (struct channel *channel) +{ + size_t n_users = channel->users_len; + struct channel_user_sort_entry entries[n_users], *p = entries; + LIST_FOR_EACH (struct channel_user, iter, channel->users) + { + p->s = channel->s; + p->channel_user = iter; + p++; + } + + qsort (entries, n_users, sizeof *entries, channel_user_sort_entry_cmp); + + channel->users = NULL; + while (p-- != entries) + LIST_PREPEND (channel->users, p->channel_user); +} + +static char * +make_channel_users_list (struct channel *channel) +{ + size_t n_users = channel->users_len; + struct channel_user_sort_entry entries[n_users], *p = entries; + LIST_FOR_EACH (struct channel_user, iter, channel->users) + { + p->s = channel->s; + p->channel_user = iter; + p++; + } + + qsort (entries, n_users, sizeof *entries, channel_user_sort_entry_cmp); + + // Make names of users that are away italicised, constructing a formatter + // and adding a new attribute seems like unnecessary work + struct str list = str_make (); + for (size_t i = 0; i < n_users; i++) + { + struct channel_user *channel_user = entries[i].channel_user; + if (channel_user->user->away) str_append_c (&list, '\x1d'); + irc_get_channel_user_prefix (channel->s, channel_user, &list); + str_append (&list, channel_user->user->nickname); + if (channel_user->user->away) str_append_c (&list, '\x1d'); + str_append_c (&list, ' '); + } + if (list.len) + list.str[--list.len] = '\0'; + return str_steal (&list); +} + +static void +irc_sync_channel_user (struct channel *channel, const char *nickname, + const char *prefixes) +{ + struct user *user = irc_get_or_make_user (channel->s, nickname); + struct channel_user *channel_user = + irc_channel_get_user (channel, user); + if (!channel_user) + { + irc_channel_link_user (channel, user, prefixes); + return; + } + + user_unref (user); + + // If our idea of the user's modes disagrees with what the server's + // sent us (the most powerful modes differ), use the latter one + if (channel_user->prefixes[0] != prefixes[0]) + cstr_set (&channel_user->prefixes, xstrdup (prefixes)); +} + +static void +irc_process_names_finish (struct channel *channel) +{ + struct server *s = channel->s; + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel->name); + if (buffer) + { + log_server_status (channel->s, buffer, "Users on #S: #&m", + channel->name, make_channel_users_list (channel)); + } +} + +static void +irc_process_names (struct channel *channel) +{ + struct str_map present = str_map_make (NULL); + present.key_xfrm = channel->s->irc_strxfrm; + + // Either that, or there is no other inhabitant, and sorting does nothing + bool we_have_just_joined = channel->users_len == 1; + + struct strv *updates = &channel->names_buf; + for (size_t i = 0; i < updates->len; i++) + { + const char *item = updates->vector[i]; + size_t n_prefixes = strspn (item, channel->s->irc_chanuser_prefixes); + const char *nickname = item + n_prefixes; + + // Store the nickname in a hashset + str_map_set (&present, nickname, (void *) 1); + + char *prefixes = xstrndup (item, n_prefixes); + irc_sync_channel_user (channel, nickname, prefixes); + free (prefixes); + } + + // Get rid of channel users missing from "updates" + LIST_FOR_EACH (struct channel_user, iter, channel->users) + if (!str_map_find (&present, iter->user->nickname)) + irc_channel_unlink_user (channel, iter); + + str_map_free (&present); + strv_reset (&channel->names_buf); + + if (we_have_just_joined) + irc_sort_channel_users (channel); + if (!channel->show_names_after_who) + irc_process_names_finish (channel); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +irc_handle_rpl_endofnames (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2) + return; + + const char *channel_name = msg->params.vector[1]; + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + if (!strcmp (channel_name, "*")) + { + struct str_map_iter iter = str_map_iter_make (&s->irc_channels); + struct channel *channel; + while ((channel = str_map_iter_next (&iter))) + irc_process_names (channel); + } + else if (channel) + irc_process_names (channel); +} + +static bool +irc_handle_rpl_whoreply (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 7) + return false; + + // Sequence: channel, user, host, server, nick, chars + const char *channel_name = msg->params.vector[1]; + const char *nickname = msg->params.vector[5]; + const char *chars = msg->params.vector[6]; + + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + struct user *user = str_map_find (&s->irc_users, nickname); + + // This makes sense to set only with the away-notify capability so far. + if (!channel || !channel->show_names_after_who) + return false; + + // We track ourselves by other means and we can't track PM-only users yet. + if (user && user != s->irc_user && user->channels) + user->away = *chars == 'G'; + return true; +} + +static bool +irc_handle_rpl_endofwho (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2) + return false; + + const char *target = msg->params.vector[1]; + + struct channel *channel = str_map_find (&s->irc_channels, target); + if (!channel || !channel->show_names_after_who) + return false; + + irc_process_names_finish (channel); + channel->show_names_after_who = false; + return true; +} + +static void +irc_handle_rpl_topic (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 3) + return; + + const char *channel_name = msg->params.vector[1]; + const char *topic = msg->params.vector[2]; + + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel_name); + hard_assert (channel || !buffer); + + if (channel) + irc_channel_set_topic (channel, topic); + + if (buffer) + log_server_status (s, buffer, "The topic is: #m", topic); +} + +static void +irc_handle_rpl_channelmodeis (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2) + return; + + const char *channel_name = msg->params.vector[1]; + + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel_name); + hard_assert (channel || !buffer); + + if (channel) + { + str_reset (&channel->no_param_modes); + str_map_clear (&channel->param_modes); + + irc_handle_mode_channel (channel, msg->params.vector + 1); + } + + // XXX: do we want to log a message? +} + +static char * +make_time_string (time_t time) +{ + char buf[32]; + struct tm tm; + strftime (buf, sizeof buf, "%a %b %d %Y %T", localtime_r (&time, &tm)); + return xstrdup (buf); +} + +static void +irc_handle_rpl_creationtime (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 3) + return; + + const char *channel_name = msg->params.vector[1]; + const char *creation_time = msg->params.vector[2]; + + unsigned long created; + if (!xstrtoul (&created, creation_time, 10)) + return; + + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel_name); + hard_assert (channel || !buffer); + + if (buffer) + { + log_server_status (s, buffer, "Channel created on #&s", + make_time_string (created)); + } +} + +static void +irc_handle_rpl_topicwhotime (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 4) + return; + + const char *channel_name = msg->params.vector[1]; + const char *who = msg->params.vector[2]; + const char *change_time = msg->params.vector[3]; + + unsigned long changed; + if (!xstrtoul (&changed, change_time, 10)) + return; + + struct channel *channel = str_map_find (&s->irc_channels, channel_name); + struct buffer *buffer = str_map_find (&s->irc_buffer_map, channel_name); + hard_assert (channel || !buffer); + + if (buffer) + { + log_server_status (s, buffer, "Topic set by #N on #&s", + who, make_time_string (changed)); + } +} + +static void +irc_handle_rpl_inviting (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 3) + return; + + const char *nickname = msg->params.vector[1]; + const char *channel_name = msg->params.vector[2]; + + struct buffer *buffer; + if (!(buffer = str_map_find (&s->irc_buffer_map, channel_name))) + buffer = s->buffer; + + log_server_status (s, buffer, + "You have invited #n to #S", nickname, channel_name); +} + +static void +irc_handle_err_nicknameinuse (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2) + return; + + log_server_error (s, s->buffer, + "Nickname is already in use: #S", msg->params.vector[1]); + + // Only do this while we haven't successfully registered yet + if (s->state != IRC_CONNECTED) + return; + + char *nickname = irc_fetch_next_nickname (s); + if (nickname) + { + log_server_status (s, s->buffer, "Retrying with #s...", nickname); + irc_send (s, "NICK :%s", nickname); + free (nickname); + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +irc_handle_isupport_prefix (struct server *s, char *value) +{ + char *modes = value; + char *prefixes = strchr (value, ')'); + size_t n_prefixes = prefixes - modes; + if (*modes++ != '(' || !prefixes++ || strlen (value) != 2 * n_prefixes--) + return; + + cstr_set (&s->irc_chanuser_modes, xstrndup (modes, n_prefixes)); + cstr_set (&s->irc_chanuser_prefixes, xstrndup (prefixes, n_prefixes)); +} + +static void +irc_handle_isupport_casemapping (struct server *s, char *value) +{ + if (!strcmp (value, "ascii")) + irc_set_casemapping (s, tolower_ascii, tolower_ascii_strxfrm); + else if (!strcmp (value, "rfc1459")) + irc_set_casemapping (s, irc_tolower, irc_strxfrm); + else if (!strcmp (value, "rfc1459-strict")) + irc_set_casemapping (s, irc_tolower_strict, irc_strxfrm_strict); +} + +static void +irc_handle_isupport_chantypes (struct server *s, char *value) +{ + cstr_set (&s->irc_chantypes, xstrdup (value)); +} + +static void +irc_handle_isupport_idchan (struct server *s, char *value) +{ + struct str prefixes = str_make (); + struct strv v = strv_make (); + cstr_split (value, ",", true, &v); + for (size_t i = 0; i < v.len; i++) + { + // Not using or validating the numeric part + const char *pair = v.vector[i]; + const char *colon = strchr (pair, ':'); + if (colon) + str_append_data (&prefixes, pair, colon - pair); + } + strv_free (&v); + cstr_set (&s->irc_idchan_prefixes, str_steal (&prefixes)); +} + +static void +irc_handle_isupport_statusmsg (struct server *s, char *value) +{ + cstr_set (&s->irc_statusmsg, xstrdup (value)); +} + +static void +irc_handle_isupport_extban (struct server *s, char *value) +{ + s->irc_extban_prefix = 0; + if (*value && *value != ',') + s->irc_extban_prefix = *value++; + + cstr_set (&s->irc_extban_types, xstrdup (*value == ',' ? ++value : "")); +} + +static void +irc_handle_isupport_chanmodes (struct server *s, char *value) +{ + struct strv v = strv_make (); + cstr_split (value, ",", true, &v); + if (v.len >= 4) + { + cstr_set (&s->irc_chanmodes_list, xstrdup (v.vector[0])); + cstr_set (&s->irc_chanmodes_param_always, xstrdup (v.vector[1])); + cstr_set (&s->irc_chanmodes_param_when_set, xstrdup (v.vector[2])); + cstr_set (&s->irc_chanmodes_param_never, xstrdup (v.vector[3])); + } + strv_free (&v); +} + +static void +irc_handle_isupport_modes (struct server *s, char *value) +{ + unsigned long modes; + if (!*value) + s->irc_max_modes = UINT_MAX; + else if (xstrtoul (&modes, value, 10) && modes && modes <= UINT_MAX) + s->irc_max_modes = modes; +} + +static void +unescape_isupport_value (const char *value, struct str *output) +{ + const char *alphabet = "0123456789abcdef", *a, *b; + for (const char *p = value; *p; p++) + { + if (p[0] == '\\' + && p[1] == 'x' + && p[2] && (a = strchr (alphabet, tolower_ascii (p[2]))) + && p[3] && (b = strchr (alphabet, tolower_ascii (p[3])))) + { + str_append_c (output, (a - alphabet) << 4 | (b - alphabet)); + p += 3; + } + else + str_append_c (output, *p); + } +} + +static void +dispatch_isupport (struct server *s, const char *name, char *value) +{ +#define MATCH(from, to) if (!strcmp (name, (from))) { (to) (s, value); return; } + + // TODO: also make use of TARGMAX to split client commands as necessary + + MATCH ("PREFIX", irc_handle_isupport_prefix); + MATCH ("CASEMAPPING", irc_handle_isupport_casemapping); + MATCH ("CHANTYPES", irc_handle_isupport_chantypes); + MATCH ("IDCHAN", irc_handle_isupport_idchan); + MATCH ("STATUSMSG", irc_handle_isupport_statusmsg); + MATCH ("EXTBAN", irc_handle_isupport_extban); + MATCH ("CHANMODES", irc_handle_isupport_chanmodes); + MATCH ("MODES", irc_handle_isupport_modes); + +#undef MATCH +} + +static void +irc_handle_rpl_isupport (struct server *s, const struct irc_message *msg) +{ + if (msg->params.len < 2) + return; + + for (size_t i = 1; i < msg->params.len - 1; i++) + { + // TODO: if the parameter starts with "-", it resets to default + char *param = msg->params.vector[i]; + char *value = param + strcspn (param, "="); + if (*value) *value++ = '\0'; + + struct str value_unescaped = str_make (); + unescape_isupport_value (value, &value_unescaped); + dispatch_isupport (s, param, value_unescaped.str); + str_free (&value_unescaped); + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +irc_adjust_motd (char **motd) +{ + // Heuristic, force MOTD to be monospace in graphical frontends. + if (!strchr (*motd, '\x11')) + { + struct str s = str_make (); + str_append_c (&s, '\x11'); + for (const char *p = *motd; *p; p++) + { + str_append_c (&s, *p); + if (*p == '\x0f') + str_append_c (&s, '\x11'); + } + cstr_set (motd, str_steal (&s)); + } +} + +static void +irc_process_numeric (struct server *s, + const struct irc_message *msg, unsigned long numeric) +{ + // Numerics typically have human-readable information + + // Get rid of the first parameter, if there's any at all, + // as it contains our nickname and is of no practical use to the user + struct strv copy = strv_make (); + strv_append_vector (©, msg->params.vector + !!msg->params.len); + + struct buffer *buffer = s->buffer; + int flags = 0; + switch (numeric) + { + case IRC_RPL_WELCOME: + irc_on_registered (s, msg->params.vector[0]); + + // We still issue a USERHOST anyway as this is in general unreliable + if (msg->params.len == 2) + irc_try_parse_welcome_for_userhost (s, msg->params.vector[1]); + break; + case IRC_RPL_MOTDSTART: + case IRC_RPL_MOTD: + if (copy.len) + irc_adjust_motd (©.vector[0]); + break; + + case IRC_RPL_ISUPPORT: + irc_handle_rpl_isupport (s, msg); break; + case IRC_RPL_USERHOST: + irc_handle_rpl_userhost (s, msg); break; + case IRC_RPL_UMODEIS: + irc_handle_rpl_umodeis (s, msg); buffer = NULL; break; + case IRC_RPL_NAMREPLY: + irc_handle_rpl_namreply (s, msg); buffer = NULL; break; + case IRC_RPL_ENDOFNAMES: + irc_handle_rpl_endofnames (s, msg); buffer = NULL; break; + case IRC_RPL_TOPIC: + irc_handle_rpl_topic (s, msg); buffer = NULL; break; + case IRC_RPL_CHANNELMODEIS: + irc_handle_rpl_channelmodeis (s, msg); buffer = NULL; break; + case IRC_RPL_CREATIONTIME: + irc_handle_rpl_creationtime (s, msg); buffer = NULL; break; + case IRC_RPL_TOPICWHOTIME: + irc_handle_rpl_topicwhotime (s, msg); buffer = NULL; break; + case IRC_RPL_INVITING: + irc_handle_rpl_inviting (s, msg); buffer = NULL; break; + + case IRC_ERR_NICKNAMEINUSE: + irc_handle_err_nicknameinuse (s, msg); buffer = NULL; break; + + // Auto-away spams server buffers with activity + case IRC_RPL_NOWAWAY: + flags |= BUFFER_LINE_UNIMPORTANT; + if (s->irc_user) s->irc_user->away = true; + break; + case IRC_RPL_UNAWAY: + flags |= BUFFER_LINE_UNIMPORTANT; + if (s->irc_user) s->irc_user->away = false; + break; + + case IRC_RPL_WHOREPLY: + if (irc_handle_rpl_whoreply (s, msg)) buffer = NULL; + break; + case IRC_RPL_ENDOFWHO: + if (irc_handle_rpl_endofwho (s, msg)) buffer = NULL; + break; + + case IRC_ERR_NICKLOCKED: + case IRC_RPL_SASLSUCCESS: + case IRC_ERR_SASLFAIL: + case IRC_ERR_SASLTOOLONG: + case IRC_ERR_SASLABORTED: + case IRC_ERR_SASLALREADY: + irc_try_finish_cap_negotiation (s); + break; + + case IRC_RPL_LIST: + + case IRC_ERR_UNKNOWNCOMMAND: + case IRC_ERR_NEEDMOREPARAMS: + // Just preventing these commands from getting printed in a more + // specific buffer as that would be unwanted + break; + + default: + // If the second parameter is something we have a buffer for + // (a channel, a PM buffer), log it in that buffer. This is very basic. + // TODO: whitelist/blacklist a lot more replies in here. + // TODO: we should either strip the first parameter from the resulting + // buffer line, or at least put it in brackets + if (msg->params.len < 2) + break; + + struct buffer *x; + if ((x = str_map_find (&s->irc_buffer_map, msg->params.vector[1]))) + buffer = x; + + // A JOIN request should be split at commas, + // then for each element produce either a JOIN response, or a numeric. + (void) irc_satisfy_join (s, msg->params.vector[1]); + } + + if (buffer) + { + // Join the parameter vector back and send it to the server buffer + log_server (s, buffer, flags, BUFFER_LINE_STATUS, + "#&m", strv_join (©, " ")); + } + + strv_free (©); +} + +static void +irc_sanitize_cut_off_utf8 (char **line) +{ + // A variation on utf8_validate(), we need to detect the -2 return + const char *p = *line, *end = strchr (p, 0); + int32_t codepoint; + while ((codepoint = utf8_decode (&p, end - p)) >= 0 + && utf8_validate_cp (codepoint)) + ; + if (codepoint != -2) + return; + + struct str fixed_up = str_make (); + str_append_data (&fixed_up, *line, p - *line); + str_append (&fixed_up, "\xEF\xBF\xBD" /* U+FFFD */); + cstr_set (line, str_steal (&fixed_up)); +} + +static void +irc_process_message (const struct irc_message *msg, struct server *s) +{ + if (msg->params.len) + irc_sanitize_cut_off_utf8 (&msg->params.vector[msg->params.len - 1]); + + // TODO: make use of IRCv3.2 server-time (with fallback to unixtime_msec()) + // -> change all calls to log_{server,nick,chghost,outcoming,ctcp}*() + // to take an extra numeric argument specifying time + struct irc_handler key = { .name = msg->command }; + struct irc_handler *handler = bsearch (&key, g_irc_handlers, + N_ELEMENTS (g_irc_handlers), sizeof key, irc_handler_cmp_by_name); + if (handler) + handler->handler (s, msg); + + unsigned long numeric; + if (xstrtoul (&numeric, msg->command, 10)) + irc_process_numeric (s, msg, numeric); + + // Better always make sure everything is in sync rather than care about + // each case explicitly whether anything might have changed + refresh_prompt (s->ctx); +} + +// --- Message autosplitting magic --------------------------------------------- + +// This is a rather basic algorithm; something like ICU with proper +// locale specification would be needed to make it work better. + +static size_t +wrap_text_for_single_line (const char *text, struct irc_char_attrs *attrs, + size_t text_len, size_t target_len, struct str *output) +{ + size_t eaten = 0; + + // First try going word by word + const char *word_start; + const char *word_end = text + strcspn (text, " "); + size_t word_len = word_end - text; + while (target_len && word_len <= target_len) + { + if (word_len) + { + str_append_data (output, text, word_len); + + text += word_len; + eaten += word_len; + target_len -= word_len; + } + + // Find the next word's end + word_start = text + strspn (text, " "); + word_end = word_start + strcspn (word_start, " "); + word_len = word_end - text; + } + + if (eaten) + // Discard whitespace between words if split + return eaten + (word_start - text); + + // And if that doesn't help, cut the longest valid block of characters + for (size_t i = 1; i <= text_len && i <= target_len; i++) + if (i == text_len || attrs[i].starts_at_boundary) + eaten = i; + + str_append_data (output, text, eaten); + return eaten; +} + +// In practice, this should never fail at all, although it's not guaranteed +static bool +wrap_message (const char *message, + int line_max, struct strv *output, struct error **e) +{ + size_t message_left = strlen (message), i = 0; + struct irc_char_attrs *attrs = irc_analyze_text (message, message_left); + struct str m = str_make (); + if (line_max <= 0) + goto error; + + while (m.len + message_left > (size_t) line_max) + { + size_t eaten = wrap_text_for_single_line + (message + i, attrs + i, message_left, line_max - m.len, &m); + if (!eaten) + goto error; + + strv_append_owned (output, str_steal (&m)); + m = str_make (); + + i += eaten; + if (!(message_left -= eaten)) + break; + + irc_serialize_char_attrs (attrs + i, &m); + if (m.len >= (size_t) line_max) + { + print_debug ("formatting continuation too long"); + str_reset (&m); + } + } + if (message_left) + strv_append_owned (output, + xstrdup_printf ("%s%s", m.str, message + i)); + + free (attrs); + str_free (&m); + return true; + +error: + free (attrs); + str_free (&m); + return error_set (e, + "Message splitting was unsuccessful as there was " + "too little room for UTF-8 characters"); +} + +/// Automatically splits messages that arrive at other clients with our prefix +/// so that they don't arrive cut off by the server +static bool +irc_autosplit_message (struct server *s, const char *message, + int fixed_part, struct strv *output, struct error **e) +{ + // :<nick>!<user>@<host> <fixed-part><message> + int space_in_one_message = 0; + if (s->irc_user && s->irc_user_host) + space_in_one_message = 510 + - 1 - (int) strlen (s->irc_user->nickname) + - 1 - (int) strlen (s->irc_user_host) + - 1 - fixed_part; + + // Multiline messages can be triggered through hooks and plugins. + struct strv lines = strv_make (); + cstr_split (message, "\r\n", false, &lines); + bool success = true; + for (size_t i = 0; i < lines.len; i++) + { + // We don't always have the full info for message splitting. + if (!space_in_one_message) + strv_append (output, lines.vector[i]); + else if (!(success = + wrap_message (lines.vector[i], space_in_one_message, output, e))) + break; + } + strv_free (&lines); + return success; +} + +static void +send_autosplit_message (struct server *s, + const char *command, const char *target, const char *message, + const char *prefix, const char *suffix) +{ + struct buffer *buffer = str_map_find (&s->irc_buffer_map, target); + + // "COMMAND target * :prefix*suffix" + int fixed_part = strlen (command) + 1 + strlen (target) + 1 + 1 + + strlen (prefix) + strlen (suffix); + + struct strv lines = strv_make (); + struct error *e = NULL; + if (!irc_autosplit_message (s, message, fixed_part, &lines, &e)) + { + log_server_error (s, buffer ? buffer : s->buffer, "#s", e->message); + error_free (e); + } + else + { + for (size_t i = 0; i < lines.len; i++) + irc_send (s, "%s %s :%s%s%s", command, target, + prefix, lines.vector[i], suffix); + } + strv_free (&lines); +} + +#define SEND_AUTOSPLIT_ACTION(s, target, message) \ + send_autosplit_message ((s), "PRIVMSG", (target), (message), \ + "\x01" "ACTION ", "\x01") + +#define SEND_AUTOSPLIT_PRIVMSG(s, target, message) \ + send_autosplit_message ((s), "PRIVMSG", (target), (message), "", "") + +#define SEND_AUTOSPLIT_NOTICE(s, target, message) \ + send_autosplit_message ((s), "NOTICE", (target), (message), "", "") + +// --- Configuration dumper ---------------------------------------------------- + +struct config_dump_data +{ + struct strv path; ///< Levels + struct strv *output; ///< Where to place new entries +}; + +static void config_dump_item + (struct config_item *item, struct config_dump_data *data); + +static void +config_dump_children + (struct config_item *object, struct config_dump_data *data) +{ + hard_assert (object->type == CONFIG_ITEM_OBJECT); + + struct str_map_iter iter = str_map_iter_make (&object->value.object); + struct config_item *child; + while ((child = str_map_iter_next (&iter))) + { + strv_append_owned (&data->path, iter.link->key); + config_dump_item (child, data); + strv_steal (&data->path, data->path.len - 1); + } +} + +static void +config_dump_item (struct config_item *item, struct config_dump_data *data) +{ + // Empty objects will show as such + if (item->type == CONFIG_ITEM_OBJECT + && item->value.object.len) + { + config_dump_children (item, data); + return; + } + + // Currently there's no reason for us to dump unknown items + struct config_schema *schema = item->schema; + if (!schema) + return; + + struct str line = str_make (); + if (data->path.len) + str_append (&line, data->path.vector[0]); + for (size_t i = 1; i < data->path.len; i++) + str_append_printf (&line, ".%s", data->path.vector[i]); + + struct str value = str_make (); + config_item_write (item, false, &value); + + // Don't bother writing out null values everywhere + bool has_default = schema && schema->default_; + if (item->type != CONFIG_ITEM_NULL || has_default) + { + str_append (&line, " = "); + str_append_str (&line, &value); + } + + if (!schema) + str_append (&line, " (unrecognized)"); + else if (has_default && strcmp (schema->default_, value.str)) + str_append_printf (&line, " (default: %s)", schema->default_); + else if (!has_default && item->type != CONFIG_ITEM_NULL) + str_append_printf (&line, " (default: %s)", "null"); + + str_free (&value); + strv_append_owned (data->output, str_steal (&line)); +} + +static void +config_dump (struct config_item *root, struct strv *output) +{ + struct config_dump_data data; + data.path = strv_make (); + data.output = output; + + config_dump_item (root, &data); + + hard_assert (!data.path.len); + strv_free (&data.path); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +strv_sort_cb (const void *a, const void *b) +{ + return strcmp (*(const char **) a, *(const char **) b); +} + +static void +strv_sort (struct strv *self) +{ + qsort (self->vector, self->len, sizeof *self->vector, strv_sort_cb); +} + +static void +dump_matching_options + (struct config_item *root, const char *mask, struct strv *output) +{ + config_dump (root, output); + strv_sort (output); + + // Filter out results by wildcard matching + for (size_t i = 0; i < output->len; i++) + { + // Yeah, I know + char *key = cstr_cut_until (output->vector[i], " "); + if (fnmatch (mask, key, 0)) + strv_remove (output, i--); + free (key); + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +save_configuration (struct app_context *ctx) +{ + struct str data = str_make (); + serialize_configuration (ctx->config.root, &data); + + struct error *e = NULL; + char *filename = write_configuration_file (NULL, &data, &e); + str_free (&data); + + if (!filename) + { + log_global_error (ctx, + "#s: #s", "Saving configuration failed", e->message); + error_free (e); + } + else + log_global_status (ctx, "Configuration written to `#s'", filename); + free (filename); +} + +// --- Server management ------------------------------------------------------- + +static bool +validate_server_name (const char *name) +{ + for (const unsigned char *p = (const unsigned char *) name; *p; p++) + if (iscntrl_ascii (*p) || *p == '.') + return false; + return true; +} + +static const char * +check_server_name_for_addition (struct app_context *ctx, const char *name) +{ + if (!strcasecmp_ascii (name, ctx->global_buffer->name)) + return "name collides with the global buffer"; + if (str_map_find (&ctx->servers, name)) + return "server already exists"; + if (!validate_server_name (name)) + return "invalid server name"; + return NULL; +} + +static struct server * +server_add (struct app_context *ctx, + const char *name, struct config_item *subtree) +{ + hard_assert (!str_map_find (&ctx->servers, name)); + + struct server *s = server_new (&ctx->poller); + s->ctx = ctx; + s->name = xstrdup (name); + str_map_set (&ctx->servers, s->name, s); + s->config = subtree; + + // Add a buffer and activate it + struct buffer *buffer = s->buffer = buffer_new (ctx->input, + BUFFER_SERVER, irc_make_buffer_name (s, NULL)); + buffer->server = s; + + buffer_add (ctx, buffer); + buffer_activate (ctx, buffer); + + config_schema_apply_to_object (g_config_server, subtree, s); + config_schema_call_changed (subtree); + + if (get_config_boolean (s->config, "autoconnect")) + // Connect to the server ASAP + poller_timer_set (&s->reconnect_tmr, 0); + return s; +} + +static void +server_add_new (struct app_context *ctx, const char *name) +{ + // Note that there may already be something in the configuration under + // that key that we've ignored earlier, and there may also be + // a case-insensitive conflict. Those things may only happen as a result + // of manual edits to the configuration, though, and they're not really + // going to break anything. They only cause surprises when loading. + struct str_map *servers = get_servers_config (ctx); + struct config_item *subtree = config_item_object (); + str_map_set (servers, name, subtree); + + struct server *s = server_add (ctx, name, subtree); + struct error *e = NULL; + if (!irc_autofill_user_info (s, &e)) + { + log_server_error (s, s->buffer, + "#s: #s", "Failed to fill in user details", e->message); + error_free (e); + } +} + +static void +server_remove (struct app_context *ctx, struct server *s) +{ + hard_assert (!irc_is_connected (s)); + + if (s->buffer) + buffer_remove_safe (ctx, s->buffer); + + relay_prepare_server_remove (ctx, s); + relay_broadcast (ctx); + + struct str_map_unset_iter iter = + str_map_unset_iter_make (&s->irc_buffer_map); + struct buffer *buffer; + while ((buffer = str_map_unset_iter_next (&iter))) + buffer_remove_safe (ctx, buffer); + str_map_unset_iter_free (&iter); + + hard_assert (!s->buffer); + hard_assert (!s->irc_buffer_map.len); + hard_assert (!s->irc_channels.len); + soft_assert (!s->irc_users.len); + + str_map_set (get_servers_config (ctx), s->name, NULL); + s->config = NULL; + + // This actually destroys the server as it's owned by the map + str_map_set (&ctx->servers, s->name, NULL); +} + +static void +server_rename (struct app_context *ctx, struct server *s, const char *new_name) +{ + hard_assert (!str_map_find (&ctx->servers, new_name)); + + relay_prepare_server_rename (ctx, s, new_name); + relay_broadcast (ctx); + + str_map_set (&ctx->servers, new_name, + str_map_steal (&ctx->servers, s->name)); + + struct str_map *servers = get_servers_config (ctx); + str_map_set (servers, new_name, str_map_steal (servers, s->name)); + + cstr_set (&s->name, xstrdup (new_name)); + buffer_rename (ctx, s->buffer, new_name); + + struct str_map_iter iter = str_map_iter_make (&s->irc_buffer_map); + struct buffer *buffer; + while ((buffer = str_map_iter_next (&iter))) + { + char *x = NULL; + switch (buffer->type) + { + case BUFFER_PM: + x = irc_make_buffer_name (s, buffer->user->nickname); + break; + case BUFFER_CHANNEL: + x = irc_make_buffer_name (s, buffer->channel->name); + break; + default: + hard_assert (!"unexpected type of server-related buffer"); + } + buffer_rename (ctx, buffer, x); + free (x); + } +} + +// --- Plugins ----------------------------------------------------------------- + +/// Returns the basename of the plugin's name without any extensions, +/// or NULL if the name isn't suitable (starts with a dot) +static char * +plugin_config_name (struct plugin *self) +{ + const char *begin = self->name; + for (const char *p = begin; *p; ) + if (*p++ == '/') + begin = p; + + size_t len = strcspn (begin, "."); + if (!len) + return NULL; + + // XXX: we might also allow arbitrary strings as object keys (except dots) + char *copy = xstrndup (begin, len); + for (char *p = copy; *p; p++) + if (!config_tokenizer_is_word_char (*p)) + *p = '_'; + return copy; +} + +// --- Lua --------------------------------------------------------------------- + +// Each plugin has its own Lua state object, so that a/ they don't disturb each +// other and b/ unloading a plugin releases all resources. +// +// References to internal objects (buffers, servers) are all weak. + +#ifdef HAVE_LUA + +struct lua_plugin +{ + struct plugin super; ///< The structure we're deriving + struct app_context *ctx; ///< Application context + lua_State *L; ///< Lua state for the main thread + + struct lua_schema_item *schemas; ///< Registered schema items +}; + +static void +lua_plugin_gc (struct plugin *self_) +{ + struct lua_plugin *self = (struct lua_plugin *) self_; + lua_gc (self->L, LUA_GCCOLLECT, 0 /* Lua 5.3 required, 5.4 varargs */); +} + +static void +lua_plugin_free (struct plugin *self_) +{ + struct lua_plugin *self = (struct lua_plugin *) self_; + lua_close (self->L); +} + +struct plugin_vtable lua_plugin_vtable = +{ + .gc = lua_plugin_gc, + .free = lua_plugin_free, +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// The registry can be used as a cache for weakly referenced objects + +static bool +lua_cache_get (lua_State *L, void *object) +{ + lua_rawgetp (L, LUA_REGISTRYINDEX, object); + if (lua_isnil (L, -1)) + { + lua_pop (L, 1); + return false; + } + return true; +} + +static void +lua_cache_store (lua_State *L, void *object, int index) +{ + lua_pushvalue (L, index); + lua_rawsetp (L, LUA_REGISTRYINDEX, object); +} + +static void +lua_cache_invalidate (lua_State *L, void *object) +{ + lua_pushnil (L); + lua_rawsetp (L, LUA_REGISTRYINDEX, object); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +/// Append a traceback to all errors so that we can later extract it +static int +lua_plugin_error_handler (lua_State *L) +{ + luaL_traceback (L, L, luaL_checkstring (L, 1), 1); + return 1; +} + +static bool +lua_plugin_process_error (struct lua_plugin *self, const char *message, + struct error **e) +{ + struct strv v = strv_make (); + cstr_split (message, "\n", true, &v); + + if (v.len < 2) + error_set (e, "%s", message); + else + { + error_set (e, "%s", v.vector[0]); + log_global_debug (self->ctx, "Lua: plugin \"#s\": #s", + self->super.name, v.vector[1]); + for (size_t i = 2; i < v.len; i++) + log_global_debug (self->ctx, " #s", v.vector[i]); + } + + strv_free (&v); + return false; +} + +/// Call a Lua function and process errors using our special error handler +static bool +lua_plugin_call (struct lua_plugin *self, + int n_params, int n_results, struct error **e) +{ + // XXX: this may eventually be called from a thread, then this is wrong + lua_State *L = self->L; + + // We need to pop the error handler at the end + lua_pushcfunction (L, lua_plugin_error_handler); + int error_handler_idx = -n_params - 2; + lua_insert (L, error_handler_idx); + + if (!lua_pcall (L, n_params, n_results, error_handler_idx)) + { + lua_remove (L, -n_results - 1); + return true; + } + + (void) lua_plugin_process_error (self, lua_tostring (L, -1), e); + lua_pop (L, 2); + return false; +} + +/// Convenience function; replaces the "original" string or produces an error +static bool +lua_plugin_handle_string_filter_result (struct lua_plugin *self, + char **original, bool utf8, struct error **e) +{ + lua_State *L = self->L; + if (lua_isnil (L, -1)) + { + cstr_set (original, NULL); + return true; + } + if (!lua_isstring (L, -1)) + return error_set (e, "must return either a string or nil"); + + size_t len; + const char *processed = lua_tolstring (L, -1, &len); + if (utf8 && !utf8_validate (processed, len)) + return error_set (e, "must return valid UTF-8"); + + // Only replace the string if it's different + if (strcmp (processed, *original)) + cstr_set (original, xstrdup (processed)); + return true; +} + +static const char * +lua_plugin_check_utf8 (lua_State *L, int arg) +{ + size_t len; + const char *s = luaL_checklstring (L, arg, &len); + luaL_argcheck (L, utf8_validate (s, len), arg, "must be valid UTF-8"); + return s; +} + +static void +lua_plugin_log_error + (struct lua_plugin *self, const char *where, struct error *error) +{ + log_global_error (self->ctx, "Lua: plugin \"#s\": #s: #s", + self->super.name, where, error->message); + error_free (error); +} + +/// Pop "n" values from the stack into a table, using their indexes as keys +static void +lua_plugin_pack (lua_State *L, int n) +{ + lua_createtable (L, n, 0); + lua_insert (L, -n - 1); + for (int i = n; i; i--) + lua_rawseti (L, -i - 1, i); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +lua_plugin_kv (lua_State *L, const char *key, const char *value) +{ + lua_pushstring (L, value); + lua_setfield (L, -2, key); +} + +static void +lua_plugin_push_message (lua_State *L, const struct irc_message *msg) +{ + lua_createtable (L, 0, 4); + + lua_createtable (L, msg->tags.len, 0); + struct str_map_iter iter = str_map_iter_make (&msg->tags); + const char *value; + while ((value = str_map_iter_next (&iter))) + lua_plugin_kv (L, iter.link->key, value); + lua_setfield (L, -2, "tags"); + + // TODO: parse the prefix further? + if (msg->prefix) lua_plugin_kv (L, "prefix", msg->prefix); + if (msg->command) lua_plugin_kv (L, "command", msg->command); + + lua_createtable (L, msg->params.len, 0); + for (size_t i = 0; i < msg->params.len; i++) + { + lua_pushstring (L, msg->params.vector[i]); + lua_rawseti (L, -2, i + 1); + } + lua_setfield (L, -2, "params"); +} + +static int +lua_plugin_parse (lua_State *L) +{ + struct irc_message msg; + irc_parse_message (&msg, luaL_checkstring (L, 1)); + lua_plugin_push_message (L, &msg); + irc_free_message (&msg); + return 1; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Lua code can use weakly referenced wrappers for internal objects. + +typedef struct weak_ref_link * + (*lua_weak_ref_fn) (void *object, destroy_cb_fn cb, void *user_data); +typedef void (*lua_weak_unref_fn) (void *object, struct weak_ref_link **link); + +struct lua_weak_info +{ + const char *name; ///< Metatable name + struct ispect_field *ispect; ///< Introspection data + + lua_weak_ref_fn ref; ///< Weak link invalidator + lua_weak_unref_fn unref; ///< Weak link generator +}; + +struct lua_weak +{ + struct lua_plugin *plugin; ///< The plugin we belong to + struct lua_weak_info *info; ///< Introspection data + void *object; ///< The object + struct weak_ref_link *weak_ref; ///< A weak reference link +}; + +static void +lua_weak_invalidate (void *object, void *user_data) +{ + struct lua_weak *wrapper = user_data; + wrapper->object = NULL; + wrapper->weak_ref = NULL; + // This can in theory call the GC, order isn't arbitrary here + lua_cache_invalidate (wrapper->plugin->L, object); +} + +static void +lua_weak_push (lua_State *L, struct lua_plugin *plugin, void *object, + struct lua_weak_info *info) +{ + if (!object) + { + lua_pushnil (L); + return; + } + if (lua_cache_get (L, object)) + return; + + struct lua_weak *wrapper = lua_newuserdata (L, sizeof *wrapper); + luaL_setmetatable (L, info->name); + wrapper->plugin = plugin; + wrapper->info = info; + wrapper->object = object; + wrapper->weak_ref = NULL; + if (info->ref) + wrapper->weak_ref = info->ref (object, lua_weak_invalidate, wrapper); + lua_cache_store (L, object, -1); +} + +static int +lua_weak_gc (lua_State *L, const struct lua_weak_info *info) +{ + struct lua_weak *wrapper = luaL_checkudata (L, 1, info->name); + if (wrapper->object) + { + lua_cache_invalidate (L, wrapper->object); + if (info->unref) + info->unref (wrapper->object, &wrapper->weak_ref); + wrapper->object = NULL; + } + return 0; +} + +static struct lua_weak * +lua_weak_deref (lua_State *L, const struct lua_weak_info *info) +{ + struct lua_weak *weak = luaL_checkudata (L, 1, info->name); + luaL_argcheck (L, weak->object, 1, "dead reference used"); + return weak; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +#define LUA_WEAK_DECLARE(id, metatable_id) \ + static struct lua_weak_info lua_ ## id ## _info = \ + { \ + .name = metatable_id, \ + .ispect = g_ ## id ## _ispect, \ + .ref = (lua_weak_ref_fn) id ## _weak_ref, \ + .unref = (lua_weak_unref_fn) id ## _weak_unref, \ + }; + +#define XLUA_USER_METATABLE "user" ///< Identifier for Lua metatable +#define XLUA_CHANNEL_METATABLE "channel" ///< Identifier for Lua metatable +#define XLUA_BUFFER_METATABLE "buffer" ///< Identifier for Lua metatable +#define XLUA_SERVER_METATABLE "server" ///< Identifier for Lua metatable + +LUA_WEAK_DECLARE (user, XLUA_USER_METATABLE) +LUA_WEAK_DECLARE (channel, XLUA_CHANNEL_METATABLE) +LUA_WEAK_DECLARE (buffer, XLUA_BUFFER_METATABLE) +LUA_WEAK_DECLARE (server, XLUA_SERVER_METATABLE) + +// The global context is kind of fake and doesn't have any ref-counting, +// however it's still very much an object +static struct lua_weak_info lua_ctx_info = +{ + .name = PROGRAM_NAME, + .ispect = g_ctx_ispect, +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +lua_user_gc (lua_State *L) +{ + return lua_weak_gc (L, &lua_user_info); +} + +static int +lua_user_get_channels (lua_State *L) +{ + struct lua_weak *wrapper = lua_weak_deref (L, &lua_user_info); + struct user *user = wrapper->object; + + int i = 1; + lua_newtable (L); + LIST_FOR_EACH (struct user_channel, iter, user->channels) + { + lua_weak_push (L, wrapper->plugin, iter->channel, &lua_channel_info); + lua_rawseti (L, -2, i++); + } + return 1; +} + +static luaL_Reg lua_user_table[] = +{ + { "__gc", lua_user_gc }, + { "get_channels", lua_user_get_channels }, + { NULL, NULL } +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +lua_channel_gc (lua_State *L) +{ + return lua_weak_gc (L, &lua_channel_info); +} + +static int +lua_channel_get_users (lua_State *L) +{ + struct lua_weak *wrapper = lua_weak_deref (L, &lua_channel_info); + struct channel *channel = wrapper->object; + + int i = 1; + lua_newtable (L); + LIST_FOR_EACH (struct channel_user, iter, channel->users) + { + lua_createtable (L, 0, 2); + lua_weak_push (L, wrapper->plugin, iter->user, &lua_user_info); + lua_setfield (L, -2, "user"); + lua_plugin_kv (L, "prefixes", iter->prefixes); + + lua_rawseti (L, -2, i++); + } + return 1; +} + +static luaL_Reg lua_channel_table[] = +{ + { "__gc", lua_channel_gc }, + { "get_users", lua_channel_get_users }, + { NULL, NULL } +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +lua_buffer_gc (lua_State *L) +{ + return lua_weak_gc (L, &lua_buffer_info); +} + +static int +lua_buffer_log (lua_State *L) +{ + struct lua_weak *wrapper = lua_weak_deref (L, &lua_buffer_info); + struct buffer *buffer = wrapper->object; + const char *message = lua_plugin_check_utf8 (L, 2); + log_full (wrapper->plugin->ctx, buffer->server, buffer, + 0, BUFFER_LINE_STATUS, "#s", message); + return 0; +} + +static int +lua_buffer_execute (lua_State *L) +{ + struct lua_weak *wrapper = lua_weak_deref (L, &lua_buffer_info); + struct buffer *buffer = wrapper->object; + const char *line = lua_plugin_check_utf8 (L, 2); + (void) process_input_line (wrapper->plugin->ctx, buffer, line, 0); + return 0; +} + +static luaL_Reg lua_buffer_table[] = +{ + { "__gc", lua_buffer_gc }, + { "log", lua_buffer_log }, + { "execute", lua_buffer_execute }, + { NULL, NULL } +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +lua_server_gc (lua_State *L) +{ + return lua_weak_gc (L, &lua_server_info); +} + +static const char * +lua_server_state_to_string (enum server_state state) +{ + switch (state) + { + case IRC_DISCONNECTED: return "disconnected"; + case IRC_CONNECTING: return "connecting"; + case IRC_CONNECTED: return "connected"; + case IRC_REGISTERED: return "registered"; + case IRC_CLOSING: return "closing"; + case IRC_HALF_CLOSED: return "half-closed"; + } + return "?"; +} + +static int +lua_server_get_state (lua_State *L) +{ + struct lua_weak *wrapper = lua_weak_deref (L, &lua_server_info); + struct server *server = wrapper->object; + lua_pushstring (L, lua_server_state_to_string (server->state)); + return 1; +} + +static int +lua_server_send (lua_State *L) +{ + struct lua_weak *wrapper = lua_weak_deref (L, &lua_server_info); + struct server *server = wrapper->object; + irc_send (server, "%s", luaL_checkstring (L, 2)); + return 0; +} + +static luaL_Reg lua_server_table[] = +{ + { "__gc", lua_server_gc }, + { "get_state", lua_server_get_state }, + { "send", lua_server_send }, + { NULL, NULL } +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +#define XLUA_HOOK_METATABLE "hook" ///< Identifier for the Lua metatable + +enum lua_hook_type +{ + XLUA_HOOK_DEFUNCT, ///< No longer functional + XLUA_HOOK_INPUT, ///< Input hook + XLUA_HOOK_IRC, ///< IRC hook + XLUA_HOOK_PROMPT, ///< Prompt hook + XLUA_HOOK_COMPLETION, ///< Autocomplete +}; + +struct lua_hook +{ + struct lua_plugin *plugin; ///< The plugin we belong to + enum lua_hook_type type; ///< Type of the hook + int ref_callback; ///< Reference to the callback + union + { + struct hook hook; ///< Hook base structure + struct input_hook input_hook; ///< Input hook + struct irc_hook irc_hook; ///< IRC hook + struct prompt_hook prompt_hook; ///< IRC hook + struct completion_hook c_hook; ///< Autocomplete hook + } + data; ///< Hook data +}; + +static int +lua_hook_unhook (lua_State *L) +{ + struct lua_hook *hook = luaL_checkudata (L, 1, XLUA_HOOK_METATABLE); + switch (hook->type) + { + case XLUA_HOOK_INPUT: + LIST_UNLINK (hook->plugin->ctx->input_hooks, &hook->data.hook); + break; + case XLUA_HOOK_IRC: + LIST_UNLINK (hook->plugin->ctx->irc_hooks, &hook->data.hook); + break; + case XLUA_HOOK_PROMPT: + LIST_UNLINK (hook->plugin->ctx->prompt_hooks, &hook->data.hook); + refresh_prompt (hook->plugin->ctx); + break; + case XLUA_HOOK_COMPLETION: + LIST_UNLINK (hook->plugin->ctx->completion_hooks, &hook->data.hook); + break; + default: + hard_assert (!"invalid hook type"); + case XLUA_HOOK_DEFUNCT: + break; + } + + luaL_unref (L, LUA_REGISTRYINDEX, hook->ref_callback); + hook->ref_callback = LUA_REFNIL; + + // The hook no longer has to stay alive + hook->type = XLUA_HOOK_DEFUNCT; + lua_cache_invalidate (L, hook); + return 0; +} + +// The hook dies either when the plugin requests it or at plugin unload +static luaL_Reg lua_hook_table[] = +{ + { "unhook", lua_hook_unhook }, + { "__gc", lua_hook_unhook }, + { NULL, NULL } +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static char * +lua_input_hook_filter (struct input_hook *self, struct buffer *buffer, + char *input) +{ + struct lua_hook *hook = + CONTAINER_OF (self, struct lua_hook, data.input_hook); + struct lua_plugin *plugin = hook->plugin; + lua_State *L = plugin->L; + + lua_rawgeti (L, LUA_REGISTRYINDEX, hook->ref_callback); + lua_rawgetp (L, LUA_REGISTRYINDEX, hook); // 1: hook + lua_weak_push (L, plugin, buffer, &lua_buffer_info); // 2: buffer + lua_pushstring (L, input); // 3: input + + struct error *e = NULL; + if (lua_plugin_call (plugin, 3, 1, &e)) + { + lua_plugin_handle_string_filter_result (plugin, &input, true, &e); + lua_pop (L, 1); + } + if (e) + lua_plugin_log_error (plugin, "input hook", e); + return input; +} + +static char * +lua_irc_hook_filter (struct irc_hook *self, struct server *s, char *message) +{ + struct lua_hook *hook = + CONTAINER_OF (self, struct lua_hook, data.irc_hook); + struct lua_plugin *plugin = hook->plugin; + lua_State *L = plugin->L; + + lua_rawgeti (L, LUA_REGISTRYINDEX, hook->ref_callback); + lua_rawgetp (L, LUA_REGISTRYINDEX, hook); // 1: hook + lua_weak_push (L, plugin, s, &lua_server_info); // 2: server + lua_pushstring (L, message); // 3: message + + struct error *e = NULL; + if (lua_plugin_call (plugin, 3, 1, &e)) + { + lua_plugin_handle_string_filter_result (plugin, &message, false, &e); + lua_pop (L, 1); + } + if (e) + lua_plugin_log_error (plugin, "IRC hook", e); + return message; +} + +static char * +lua_prompt_hook_make (struct prompt_hook *self) +{ + struct lua_hook *hook = + CONTAINER_OF (self, struct lua_hook, data.prompt_hook); + struct lua_plugin *plugin = hook->plugin; + lua_State *L = plugin->L; + + lua_rawgeti (L, LUA_REGISTRYINDEX, hook->ref_callback); + lua_rawgetp (L, LUA_REGISTRYINDEX, hook); // 1: hook + + struct error *e = NULL; + char *prompt = xstrdup (""); + if (lua_plugin_call (plugin, 1, 1, &e)) + { + lua_plugin_handle_string_filter_result (plugin, &prompt, true, &e); + lua_pop (L, 1); + } + if (e) + lua_plugin_log_error (plugin, "prompt hook", e); + return prompt; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +lua_plugin_push_completion (lua_State *L, struct completion *data) +{ + lua_createtable (L, 0, 3); + + lua_pushstring (L, data->line); + lua_setfield (L, -2, "line"); + + lua_createtable (L, data->words_len, 0); + for (size_t i = 0; i < data->words_len; i++) + { + lua_pushlstring (L, data->line + data->words[i].start, + data->words[i].end - data->words[i].start); + lua_rawseti (L, -2, i + 1); + } + lua_setfield (L, -2, "words"); + + lua_pushinteger (L, data->location); + lua_setfield (L, -2, "location"); +} + +static bool +lua_completion_hook_process_value (lua_State *L, struct strv *output, + struct error **e) +{ + if (lua_type (L, -1) != LUA_TSTRING) + { + return error_set (e, + "%s: %s", "invalid type", lua_typename (L, lua_type (L, -1))); + } + + size_t len; + const char *value = lua_tolstring (L, -1, &len); + if (!utf8_validate (value, len)) + return error_set (e, "must be valid UTF-8"); + + strv_append (output, value); + return true; +} + +static bool +lua_completion_hook_process (lua_State *L, struct strv *output, + struct error **e) +{ + if (lua_isnil (L, -1)) + return true; + if (!lua_istable (L, -1)) + return error_set (e, "must return either a table or nil"); + + bool success = true; + for (lua_Integer i = 1; success && lua_rawgeti (L, -1, i); i++) + if ((success = lua_completion_hook_process_value (L, output, e))) + lua_pop (L, 1); + lua_pop (L, 1); + return success; +} + +static void +lua_completion_hook_complete (struct completion_hook *self, + struct completion *data, const char *word, struct strv *output) +{ + struct lua_hook *hook = + CONTAINER_OF (self, struct lua_hook, data.c_hook); + struct lua_plugin *plugin = hook->plugin; + lua_State *L = plugin->L; + + lua_rawgeti (L, LUA_REGISTRYINDEX, hook->ref_callback); + lua_rawgetp (L, LUA_REGISTRYINDEX, hook); // 1: hook + lua_plugin_push_completion (L, data); // 2: data + + lua_weak_push (L, plugin, plugin->ctx->current_buffer, &lua_buffer_info); + lua_setfield (L, -2, "buffer"); + + lua_pushstring (L, word); // 3: word + + struct error *e = NULL; + if (lua_plugin_call (plugin, 3, 1, &e)) + { + lua_completion_hook_process (L, output, &e); + lua_pop (L, 1); + } + if (e) + lua_plugin_log_error (plugin, "autocomplete hook", e); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static struct lua_hook * +lua_plugin_push_hook (lua_State *L, struct lua_plugin *plugin, + int callback_index, enum lua_hook_type type, int priority) +{ + luaL_checktype (L, callback_index, LUA_TFUNCTION); + + struct lua_hook *hook = lua_newuserdata (L, sizeof *hook); + luaL_setmetatable (L, XLUA_HOOK_METATABLE); + memset (hook, 0, sizeof *hook); + hook->data.hook.priority = priority; + hook->type = type; + hook->plugin = plugin; + + lua_pushvalue (L, callback_index); + hook->ref_callback = luaL_ref (L, LUA_REGISTRYINDEX); + + // Make sure the hook doesn't get garbage collected and return it + lua_cache_store (L, hook, -1); + return hook; +} + +static int +lua_plugin_hook_input (lua_State *L) +{ + struct lua_plugin *plugin = lua_touserdata (L, lua_upvalueindex (1)); + struct lua_hook *hook = lua_plugin_push_hook + (L, plugin, 1, XLUA_HOOK_INPUT, luaL_optinteger (L, 2, 0)); + hook->data.input_hook.filter = lua_input_hook_filter; + plugin->ctx->input_hooks = + hook_insert (plugin->ctx->input_hooks, &hook->data.hook); + return 1; +} + +static int +lua_plugin_hook_irc (lua_State *L) +{ + struct lua_plugin *plugin = lua_touserdata (L, lua_upvalueindex (1)); + struct lua_hook *hook = lua_plugin_push_hook + (L, plugin, 1, XLUA_HOOK_IRC, luaL_optinteger (L, 2, 0)); + hook->data.irc_hook.filter = lua_irc_hook_filter; + plugin->ctx->irc_hooks = + hook_insert (plugin->ctx->irc_hooks, &hook->data.hook); + return 1; +} + +static int +lua_plugin_hook_prompt (lua_State *L) +{ + struct lua_plugin *plugin = lua_touserdata (L, lua_upvalueindex (1)); + struct lua_hook *hook = lua_plugin_push_hook + (L, plugin, 1, XLUA_HOOK_PROMPT, luaL_optinteger (L, 2, 0)); + hook->data.prompt_hook.make = lua_prompt_hook_make; + plugin->ctx->prompt_hooks = + hook_insert (plugin->ctx->prompt_hooks, &hook->data.hook); + refresh_prompt (plugin->ctx); + return 1; +} + +static int +lua_plugin_hook_completion (lua_State *L) +{ + struct lua_plugin *plugin = lua_touserdata (L, lua_upvalueindex (1)); + struct lua_hook *hook = lua_plugin_push_hook + (L, plugin, 1, XLUA_HOOK_COMPLETION, luaL_optinteger (L, 2, 0)); + hook->data.c_hook.complete = lua_completion_hook_complete; + plugin->ctx->completion_hooks = + hook_insert (plugin->ctx->completion_hooks, &hook->data.hook); + return 1; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +#define XLUA_SCHEMA_METATABLE "schema" ///< Identifier for the Lua metatable + +struct lua_schema_item +{ + LIST_HEADER (struct lua_schema_item) + + struct lua_plugin *plugin; ///< The plugin we belong to + struct config_item *item; ///< The item managed by the schema + struct config_schema schema; ///< Schema itself + + int ref_validate; ///< Reference to "validate" callback + int ref_on_change; ///< Reference to "on_change" callback +}; + +static void +lua_schema_item_discard (struct lua_schema_item *self) +{ + if (self->item) + { + self->item->schema = NULL; + self->item->user_data = NULL; + self->item = NULL; + LIST_UNLINK (self->plugin->schemas, self); + } + + // Now that we've disconnected from the item, allow garbage collection + lua_cache_invalidate (self->plugin->L, self); +} + +static int +lua_schema_item_gc (lua_State *L) +{ + struct lua_schema_item *self = + luaL_checkudata (L, 1, XLUA_SCHEMA_METATABLE); + lua_schema_item_discard (self); + + free ((char *) self->schema.name); + free ((char *) self->schema.comment); + free ((char *) self->schema.default_); + + luaL_unref (L, LUA_REGISTRYINDEX, self->ref_validate); + luaL_unref (L, LUA_REGISTRYINDEX, self->ref_on_change); + return 0; +} + +static luaL_Reg lua_schema_table[] = +{ + { "__gc", lua_schema_item_gc }, + { NULL, NULL } +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +/// Unfortunately this has the same problem as JSON libraries in that Lua +/// cannot store null values in containers (it has no distinct "undefined" type) +static void +lua_plugin_push_config_item (lua_State *L, const struct config_item *item) +{ + switch (item->type) + { + case CONFIG_ITEM_NULL: + lua_pushnil (L); + break; + case CONFIG_ITEM_OBJECT: + { + lua_createtable (L, 0, item->value.object.len); + + struct str_map_iter iter = str_map_iter_make (&item->value.object); + struct config_item *child; + while ((child = str_map_iter_next (&iter))) + { + lua_plugin_push_config_item (L, child); + lua_setfield (L, -2, iter.link->key); + } + break; + } + case CONFIG_ITEM_BOOLEAN: + lua_pushboolean (L, item->value.boolean); + break; + case CONFIG_ITEM_INTEGER: + lua_pushinteger (L, item->value.integer); + break; + case CONFIG_ITEM_STRING: + case CONFIG_ITEM_STRING_ARRAY: + lua_pushlstring (L, item->value.string.str, item->value.string.len); + break; + } +} + +static bool +lua_schema_item_validate (const struct config_item *item, struct error **e) +{ + struct lua_schema_item *self = item->user_data; + if (self->ref_validate == LUA_REFNIL) + return true; + + lua_State *L = self->plugin->L; + lua_rawgeti (L, LUA_REGISTRYINDEX, self->ref_validate); + lua_plugin_push_config_item (L, item); + + // The callback can make use of error("...", 0) to produce nice messages + return lua_plugin_call (self->plugin, 1, 0, e); +} + +static void +lua_schema_item_on_change (struct config_item *item) +{ + struct lua_schema_item *self = item->user_data; + if (self->ref_on_change == LUA_REFNIL) + return; + + lua_State *L = self->plugin->L; + lua_rawgeti (L, LUA_REGISTRYINDEX, self->ref_on_change); + lua_plugin_push_config_item (L, item); + + struct error *e = NULL; + if (!lua_plugin_call (self->plugin, 1, 0, &e)) + lua_plugin_log_error (self->plugin, "schema on_change", e); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +lua_plugin_decode_config_item_type (const char *type) +{ + if (!strcmp (type, "null")) return CONFIG_ITEM_NULL; + if (!strcmp (type, "object")) return CONFIG_ITEM_OBJECT; + if (!strcmp (type, "boolean")) return CONFIG_ITEM_BOOLEAN; + if (!strcmp (type, "integer")) return CONFIG_ITEM_INTEGER; + if (!strcmp (type, "string")) return CONFIG_ITEM_STRING; + if (!strcmp (type, "string_array")) return CONFIG_ITEM_STRING_ARRAY; + return -1; +} + +static bool +lua_plugin_check_field (lua_State *L, int idx, const char *name, + int expected, bool optional) +{ + int found = lua_getfield (L, idx, name); + if (found == expected) + return true; + if (optional && found == LUA_TNIL) + return false; + + const char *message = optional + ? "invalid field \"%s\" (found: %s, expected: %s or nil)" + : "invalid or missing field \"%s\" (found: %s, expected: %s)"; + return luaL_error (L, message, name, + lua_typename (L, found), lua_typename (L, expected)); +} + +static int +lua_plugin_add_config_schema (lua_State *L, struct lua_plugin *plugin, + struct config_item *subtree, const char *name) +{ + struct config_item *item = str_map_find (&subtree->value.object, name); + // This should only ever happen because of a conflict with another plugin; + // this is the price we pay for simplicity + if (item && item->schema) + { + struct lua_schema_item *owner = item->user_data; + return luaL_error (L, "conflicting schema item: %s (owned by: %s)", + name, owner->plugin->super.name); + } + + // Create and initialize a full userdata wrapper for the schema item + struct lua_schema_item *self = lua_newuserdata (L, sizeof *self); + luaL_setmetatable (L, XLUA_SCHEMA_METATABLE); + memset (self, 0, sizeof *self); + + self->plugin = plugin; + self->ref_on_change = LUA_REFNIL; + self->ref_validate = LUA_REFNIL; + + struct config_schema *schema = &self->schema; + schema->name = xstrdup (name); + schema->comment = NULL; + schema->default_ = NULL; + schema->type = CONFIG_ITEM_NULL; + schema->on_change = lua_schema_item_on_change; + schema->validate = lua_schema_item_validate; + + // Try to update the defaults with values provided by the plugin + int values = lua_absindex (L, -2); + (void) lua_plugin_check_field (L, values, "type", LUA_TSTRING, false); + int item_type = schema->type = + lua_plugin_decode_config_item_type (lua_tostring (L, -1)); + if (item_type == -1) + return luaL_error (L, "invalid type of schema item"); + + if (lua_plugin_check_field (L, values, "comment", LUA_TSTRING, true)) + schema->comment = xstrdup (lua_tostring (L, -1)); + if (lua_plugin_check_field (L, values, "default", LUA_TSTRING, true)) + schema->default_ = xstrdup (lua_tostring (L, -1)); + + lua_pop (L, 3); + + (void) lua_plugin_check_field (L, values, "on_change", LUA_TFUNCTION, true); + self->ref_on_change = luaL_ref (L, LUA_REGISTRYINDEX); + (void) lua_plugin_check_field (L, values, "validate", LUA_TFUNCTION, true); + self->ref_validate = luaL_ref (L, LUA_REGISTRYINDEX); + + // Try to install the created schema item into our configuration + struct error *warning = NULL, *e = NULL; + item = config_schema_initialize_item + (&self->schema, subtree, self, &warning, &e); + + if (warning) + { + log_global_error (plugin->ctx, "Lua: plugin \"#s\": #s", + plugin->super.name, warning->message); + error_free (warning); + } + if (e) + { + const char *error = lua_pushstring (L, e->message); + error_free (e); + return luaL_error (L, "%s", error); + } + + self->item = item; + LIST_PREPEND (plugin->schemas, self); + + // On the stack there should be the schema table and the resulting object; + // we need to make sure Lua doesn't GC the second and get rid of them both + lua_cache_store (L, self, -1); + lua_pop (L, 2); + return 0; +} + +static int +lua_plugin_setup_config (lua_State *L) +{ + struct lua_plugin *plugin = lua_touserdata (L, lua_upvalueindex (1)); + luaL_checktype (L, 1, LUA_TTABLE); + + struct app_context *ctx = plugin->ctx; + char *config_name = plugin_config_name (&plugin->super); + if (!config_name) + return luaL_error (L, "unsuitable plugin name"); + + struct str_map *plugins = get_plugins_config (ctx); + struct config_item *subtree = str_map_find (plugins, config_name); + if (!subtree || subtree->type != CONFIG_ITEM_OBJECT) + str_map_set (plugins, config_name, (subtree = config_item_object ())); + free (config_name); + + LIST_FOR_EACH (struct lua_schema_item, iter, plugin->schemas) + lua_schema_item_discard (iter); + + // Load all schema items and apply them to the plugin's subtree + lua_pushnil (L); + while (lua_next (L, 1)) + { + if (lua_type (L, -2) != LUA_TSTRING + || lua_type (L, -1) != LUA_TTABLE) + return luaL_error (L, "%s: %s -> %s", "invalid types", + lua_typename (L, lua_type (L, -2)), + lua_typename (L, lua_type (L, -1))); + lua_plugin_add_config_schema (L, plugin, subtree, lua_tostring (L, -2)); + } + + // Let the plugin read out configuration via on_change callbacks + config_schema_call_changed (subtree); + return 0; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +/// Identifier for the Lua metatable +#define XLUA_CONNECTION_METATABLE "connection" + +struct lua_connection +{ + struct lua_plugin *plugin; ///< The plugin we belong to + struct poller_fd socket_event; ///< Socket is ready + int socket_fd; ///< Underlying connected socket + + bool got_eof; ///< Half-closed by remote host + bool closing; ///< We're closing the connection + + struct str read_buffer; ///< Read buffer + struct str write_buffer; ///< Write buffer +}; + +static void +lua_connection_update_poller (struct lua_connection *self) +{ + poller_fd_set (&self->socket_event, + self->write_buffer.len ? (POLLIN | POLLOUT) : POLLIN); +} + +static int +lua_connection_send (lua_State *L) +{ + struct lua_connection *self = + luaL_checkudata (L, 1, XLUA_CONNECTION_METATABLE); + if (self->socket_fd == -1) + return luaL_error (L, "connection has been closed"); + + size_t len; + const char *s = luaL_checklstring (L, 2, &len); + str_append_data (&self->write_buffer, s, len); + lua_connection_update_poller (self); + return 0; +} + +static void +lua_connection_discard (struct lua_connection *self) +{ + if (self->socket_fd != -1) + { + poller_fd_reset (&self->socket_event); + xclose (self->socket_fd); + self->socket_fd = -1; + + str_free (&self->read_buffer); + str_free (&self->write_buffer); + } + + // Connection is dead, we don't need to hold onto any resources anymore + lua_cache_invalidate (self->plugin->L, self); +} + +static int +lua_connection_close (lua_State *L) +{ + struct lua_connection *self = + luaL_checkudata (L, 1, XLUA_CONNECTION_METATABLE); + if (self->socket_fd != -1) + { + self->closing = true; + // NOTE: this seems to do nothing on Linux + (void) shutdown (self->socket_fd, SHUT_RD); + + // Right now we want to wait until all data is flushed to the socket + // and can't call close() here immediately -- a rewrite to use async + // would enable the user to await on either :send() or :flush(); + // a successful send() doesn't necessarily mean anything though + if (!self->write_buffer.len) + lua_connection_discard (self); + } + return 0; +} + +static int +lua_connection_gc (lua_State *L) +{ + lua_connection_discard (luaL_checkudata (L, 1, XLUA_CONNECTION_METATABLE)); + return 0; +} + +static luaL_Reg lua_connection_table[] = +{ + { "send", lua_connection_send }, + { "close", lua_connection_close }, + { "__gc", lua_connection_gc }, + { NULL, NULL } +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +lua_connection_check_fn (lua_State *L) +{ + lua_plugin_check_field (L, 1, luaL_checkstring (L, 2), LUA_TFUNCTION, true); + return 1; +} + +// We need to run it in a protected environment because of lua_getfield() +static bool +lua_connection_cb_lookup (struct lua_connection *self, const char *name, + struct error **e) +{ + lua_State *L = self->plugin->L; + lua_pushcfunction (L, lua_connection_check_fn); + hard_assert (lua_cache_get (L, self)); + lua_pushstring (L, name); + return lua_plugin_call (self->plugin, 2, 1, e); +} + +// Ideally lua_connection_cb_lookup() would return a ternary value +static bool +lua_connection_eat_nil (struct lua_connection *self) +{ + if (lua_toboolean (self->plugin->L, -1)) + return false; + lua_pop (self->plugin->L, 1); + return true; +} + +static bool +lua_connection_invoke_on_data (struct lua_connection *self, struct error **e) +{ + if (!lua_connection_cb_lookup (self, "on_data", e)) + return false; + if (lua_connection_eat_nil (self)) + return true; + + lua_pushlstring (self->plugin->L, + self->read_buffer.str, self->read_buffer.len); + return lua_plugin_call (self->plugin, 1, 0, e); +} + +static bool +lua_connection_invoke_on_eof (struct lua_connection *self, struct error **e) +{ + if (!lua_connection_cb_lookup (self, "on_eof", e)) + return false; + if (lua_connection_eat_nil (self)) + return true; + return lua_plugin_call (self->plugin, 0, 0, e); +} + +static bool +lua_connection_invoke_on_error (struct lua_connection *self, + const char *error, struct error **e) +{ + // XXX: not sure if ignoring errors after :close() is always desired; + // code might want to make sure that data are transferred successfully + if (!self->closing + && lua_connection_cb_lookup (self, "on_error", e) + && !lua_connection_eat_nil (self)) + { + lua_pushstring (self->plugin->L, error); + lua_plugin_call (self->plugin, 1, 0, e); + } + return false; +} + +static bool +lua_connection_try_read (struct lua_connection *self, struct error **e) +{ + // Avoid the read call when it's obviously not going to return any data + // and would only cause unwanted invocation of callbacks + if (self->closing || self->got_eof) + return true; + + enum socket_io_result read_result = + socket_io_try_read (self->socket_fd, &self->read_buffer); + const char *error = strerror (errno); + + // Dispatch any data that we got before an EOF or any error + if (self->read_buffer.len) + { + if (!lua_connection_invoke_on_data (self, e)) + return false; + str_reset (&self->read_buffer); + } + + if (read_result == SOCKET_IO_EOF) + { + if (!lua_connection_invoke_on_eof (self, e)) + return false; + self->got_eof = true; + } + if (read_result == SOCKET_IO_ERROR) + return lua_connection_invoke_on_error (self, error, e); + return true; +} + +static bool +lua_connection_try_write (struct lua_connection *self, struct error **e) +{ + enum socket_io_result write_result = + socket_io_try_write (self->socket_fd, &self->write_buffer); + const char *error = strerror (errno); + + if (write_result == SOCKET_IO_ERROR) + return lua_connection_invoke_on_error (self, error, e); + return !self->closing || self->write_buffer.len; +} + +static void +lua_connection_on_ready (const struct pollfd *pfd, struct lua_connection *self) +{ + (void) pfd; + + // Hold a reference so that it doesn't get collected on close() + hard_assert (lua_cache_get (self->plugin->L, self)); + + struct error *e = NULL; + bool keep = lua_connection_try_read (self, &e) + && lua_connection_try_write (self, &e); + if (e) + lua_plugin_log_error (self->plugin, "network I/O", e); + if (keep) + lua_connection_update_poller (self); + else + lua_connection_discard (self); + + lua_pop (self->plugin->L, 1); +} + +static struct lua_connection * +lua_plugin_push_connection (struct lua_plugin *plugin, int socket_fd) +{ + lua_State *L = plugin->L; + + struct lua_connection *self = lua_newuserdata (L, sizeof *self); + luaL_setmetatable (L, XLUA_CONNECTION_METATABLE); + memset (self, 0, sizeof *self); + self->plugin = plugin; + + set_blocking (socket_fd, false); + self->socket_event = poller_fd_make + (&plugin->ctx->poller, (self->socket_fd = socket_fd)); + self->socket_event.dispatcher = (poller_fd_fn) lua_connection_on_ready; + self->socket_event.user_data = self; + poller_fd_set (&self->socket_event, POLLIN); + + self->read_buffer = str_make (); + self->write_buffer = str_make (); + + // Make sure the connection doesn't get garbage collected and return it + lua_cache_store (L, self, -1); + return self; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// The script can create as many wait channels as wanted. They only actually +// do anything once they get yielded to the main lua_resume() call. + +/// Identifier for the Lua metatable +#define XLUA_WCHANNEL_METATABLE "wchannel" + +struct lua_wait_channel +{ + LIST_HEADER (struct lua_wait_channel) + + struct lua_task *task; ///< The task we're active in + + /// Check if the event is ready and eventually push values to the thread; + /// the channel then may release any resources + bool (*check) (struct lua_wait_channel *self); + + /// Release all resources held by the subclass + void (*cleanup) (struct lua_wait_channel *self); +}; + +static int +lua_wchannel_gc (lua_State *L) +{ + struct lua_wait_channel *self = + luaL_checkudata (L, 1, XLUA_WCHANNEL_METATABLE); + if (self->cleanup) + self->cleanup (self); + return 0; +} + +static luaL_Reg lua_wchannel_table[] = +{ + { "__gc", lua_wchannel_gc }, + { NULL, NULL } +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// A task encapsulates a thread so that wait channels yielded from its main +// function get waited upon by the event loop + +#define XLUA_TASK_METATABLE "task" ///< Identifier for the Lua metatable + +struct lua_task +{ + LIST_HEADER (struct lua_task) + + struct lua_plugin *plugin; ///< The plugin we belong to + lua_State *thread; ///< Lua thread + struct lua_wait_channel *active; ///< Channels we're waiting on + struct poller_idle idle; ///< Idle job +}; + +static void +lua_task_unregister_channels (struct lua_task *self) +{ + LIST_FOR_EACH (struct lua_wait_channel, iter, self->active) + { + iter->task = NULL; + LIST_UNLINK (self->active, iter); + lua_cache_invalidate (self->plugin->L, iter); + } +} + +static void +lua_task_cancel_internal (struct lua_task *self) +{ + if (self->thread) + { + lua_cache_invalidate (self->plugin->L, self->thread); + self->thread = NULL; + } + lua_task_unregister_channels (self); + poller_idle_reset (&self->idle); + + // The task no longer has to stay alive + lua_cache_invalidate (self->plugin->L, self); +} + +static int +lua_task_cancel (lua_State *L) +{ + struct lua_task *self = luaL_checkudata (L, 1, XLUA_TASK_METATABLE); + // We could also yield and make lua_task_resume() check "self->thread", + // however the main issue here is that the script should just return + luaL_argcheck (L, L != self->thread, 1, + "cannot cancel task from within itself"); + lua_task_cancel_internal (self); + return 0; +} + +#define lua_task_wakeup(self) poller_idle_set (&(self)->idle) + +static bool +lua_task_schedule (struct lua_task *self, int n, struct error **e) +{ + lua_State *L = self->thread; + for (int i = -1; -i <= n; i--) + { + struct lua_wait_channel *channel = + luaL_testudata (L, i, XLUA_WCHANNEL_METATABLE); + if (!channel) + return error_set (e, "bad argument #%d to yield: %s", -i + n + 1, + "tasks can only yield wait channels"); + if (channel->task) + return error_set (e, "bad argument #%d to yield: %s", -i + n + 1, + "wait channels can only be active in one task at most"); + } + for (int i = -1; -i <= n; i--) + { + // Quietly ignore duplicate channels + struct lua_wait_channel *channel = lua_touserdata (L, i); + if (channel->task) + continue; + + // By going in reverse the list ends up in the right order + channel->task = self; + LIST_PREPEND (self->active, channel); + lua_cache_store (self->plugin->L, channel, i); + } + lua_pop (L, n); + + // There doesn't have to be a single channel + // We can also be waiting on a channel that is already ready + lua_task_wakeup (self); + return true; +} + +static void +lua_task_resume (struct lua_task *self, int index) +{ + lua_State *L = self->thread; + bool waiting_on_multiple = self->active && self->active->next; + + // Since we've ended the wait, we don't need to hold on to them anymore + lua_task_unregister_channels (self); + + // On the first run we also have the main function on the stack, + // before any initial arguments + int n = lua_gettop (L) - (lua_status (L) == LUA_OK); + + // Pack the values in a table and prepend the index of the channel, so that + // the caller doesn't need to care about the number of return values + if (waiting_on_multiple) + { + lua_plugin_pack (L, n); + lua_pushinteger (L, index); + lua_insert (L, -2); + n = 2; + } + +#if LUA_VERSION_NUM >= 504 + int nresults = 0; + int res = lua_resume (L, NULL, n, &nresults); +#else + int res = lua_resume (L, NULL, n); + int nresults = lua_gettop (L); +#endif + + struct error *error = NULL; + if (res == LUA_YIELD) + { + // AFAIK we don't get any good error context information from here + if (lua_task_schedule (self, nresults, &error)) + return; + } + // For simplicity ignore any results from successful returns + else if (res != LUA_OK) + { + luaL_traceback (L, L, lua_tostring (L, -1), 0 /* or 1? */); + lua_plugin_process_error (self->plugin, lua_tostring (L, -1), &error); + lua_pop (L, 2); + } + if (error) + lua_plugin_log_error (self->plugin, "task", error); + lua_task_cancel_internal (self); +} + +static void +lua_task_check (struct lua_task *self) +{ + poller_idle_reset (&self->idle); + + lua_Integer i = 0; + LIST_FOR_EACH (struct lua_wait_channel, iter, self->active) + { + i++; + if (iter->check (iter)) + { + lua_task_resume (self, i); + return; + } + } + if (!self->active) + lua_task_resume (self, i); +} + +// The task dies either when it finishes, it is cancelled, or at plugin unload +static luaL_Reg lua_task_table[] = +{ + { "cancel", lua_task_cancel }, + { "__gc", lua_task_cancel }, + { NULL, NULL } +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct lua_wait_timer +{ + struct lua_wait_channel super; ///< The structure we're deriving + struct poller_timer timer; ///< Timer event + bool expired; ///< Whether the timer has expired +}; + +static bool +lua_wait_timer_check (struct lua_wait_channel *wchannel) +{ + struct lua_wait_timer *self = + CONTAINER_OF (wchannel, struct lua_wait_timer, super); + return self->super.task && self->expired; +} + +static void +lua_wait_timer_cleanup (struct lua_wait_channel *wchannel) +{ + struct lua_wait_timer *self = + CONTAINER_OF (wchannel, struct lua_wait_timer, super); + poller_timer_reset (&self->timer); +} + +static void +lua_wait_timer_dispatch (struct lua_wait_timer *self) +{ + self->expired = true; + if (self->super.task) + lua_task_wakeup (self->super.task); +} + +static int +lua_plugin_push_wait_timer (struct lua_plugin *plugin, lua_State *L, + lua_Integer timeout) +{ + struct lua_wait_timer *self = lua_newuserdata (L, sizeof *self); + luaL_setmetatable (L, XLUA_WCHANNEL_METATABLE); + memset (self, 0, sizeof *self); + + self->super.check = lua_wait_timer_check; + self->super.cleanup = lua_wait_timer_cleanup; + + self->timer = poller_timer_make (&plugin->ctx->poller); + self->timer.dispatcher = (poller_timer_fn) lua_wait_timer_dispatch; + self->timer.user_data = self; + + if (timeout) + poller_timer_set (&self->timer, timeout); + else + self->expired = true; + return 1; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct lua_wait_dial +{ + struct lua_wait_channel super; ///< The structure we're deriving + + struct lua_plugin *plugin; ///< The plugin we belong to + struct connector connector; ///< Connector object + bool active; ///< Whether the connector is alive + + struct lua_connection *connection; ///< Established connection + char *hostname; ///< Target hostname + char *last_error; ///< Connecting error, if any +}; + +static bool +lua_wait_dial_check (struct lua_wait_channel *wchannel) +{ + struct lua_wait_dial *self = + CONTAINER_OF (wchannel, struct lua_wait_dial, super); + + lua_State *L = self->super.task->thread; + if (self->connection) + { + // FIXME: this way the connection may leak -- we pass the value to the + // task manager on the stack and forget about it but still leave the + // connection in the cache. That is because right now, when Lua code + // sets up callbacks in the connection object and returns, it might + // get otherwise GC'd since nothing else keeps referencing it. + // By rewriting lua_connection using async, tasks and wait channels + // would hold a reference, allowing us to remove it from the cache. + lua_cache_get (L, self->connection); + lua_pushstring (L, self->hostname); + self->connection = NULL; + } + else if (self->last_error) + { + lua_pushnil (L); + lua_pushnil (L); + lua_pushstring (L, self->last_error); + } + else + return false; + return true; +} + +static void +lua_wait_dial_cancel (struct lua_wait_dial *self) +{ + if (self->active) + { + connector_free (&self->connector); + self->active = false; + } +} + +static void +lua_wait_dial_cleanup (struct lua_wait_channel *wchannel) +{ + struct lua_wait_dial *self = + CONTAINER_OF (wchannel, struct lua_wait_dial, super); + + lua_wait_dial_cancel (self); + if (self->connection) + lua_connection_discard (self->connection); + + free (self->hostname); + free (self->last_error); +} + +static void +lua_wait_dial_on_connected (void *user_data, int socket, const char *hostname) +{ + struct lua_wait_dial *self = user_data; + if (self->super.task) + lua_task_wakeup (self->super.task); + + self->connection = lua_plugin_push_connection (self->plugin, socket); + // TODO: use the hostname for SNI once TLS is implemented + self->hostname = xstrdup (hostname); + lua_wait_dial_cancel (self); +} + +static void +lua_wait_dial_on_failure (void *user_data) +{ + struct lua_wait_dial *self = user_data; + if (self->super.task) + lua_task_wakeup (self->super.task); + lua_wait_dial_cancel (self); +} + +static void +lua_wait_dial_on_error (void *user_data, const char *error) +{ + struct lua_wait_dial *self = user_data; + cstr_set (&self->last_error, xstrdup (error)); +} + +static int +lua_plugin_push_wait_dial (struct lua_plugin *plugin, lua_State *L, + const char *host, const char *service) +{ + struct lua_wait_dial *self = lua_newuserdata (L, sizeof *self); + luaL_setmetatable (L, XLUA_WCHANNEL_METATABLE); + memset (self, 0, sizeof *self); + + self->super.check = lua_wait_dial_check; + self->super.cleanup = lua_wait_dial_cleanup; + + struct connector *connector = &self->connector; + connector_init (connector, &plugin->ctx->poller); + connector_add_target (connector, host, service); + + connector->on_connected = lua_wait_dial_on_connected; + connector->on_connecting = NULL; + connector->on_error = lua_wait_dial_on_error; + connector->on_failure = lua_wait_dial_on_failure; + connector->user_data = self; + + self->plugin = plugin; + self->active = true; + return 1; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +lua_async_go (lua_State *L) +{ + struct lua_plugin *plugin = lua_touserdata (L, lua_upvalueindex (1)); + luaL_checktype (L, 1, LUA_TFUNCTION); + + lua_State *thread = lua_newthread (L); + lua_cache_store (L, thread, -1); + lua_pop (L, 1); + + // Move the main function w/ arguments to the thread + lua_xmove (L, thread, lua_gettop (L)); + + struct lua_task *task = lua_newuserdata (L, sizeof *task); + luaL_setmetatable (L, XLUA_TASK_METATABLE); + memset (task, 0, sizeof *task); + task->plugin = plugin; + task->thread = thread; + + task->idle = poller_idle_make (&plugin->ctx->poller); + task->idle.dispatcher = (poller_idle_fn) lua_task_check; + task->idle.user_data = task; + poller_idle_set (&task->idle); + + // Make sure the task doesn't get garbage collected and return it + lua_cache_store (L, task, -1); + return 1; +} + +static int +lua_async_timer_ms (lua_State *L) +{ + struct lua_plugin *plugin = lua_touserdata (L, lua_upvalueindex (1)); + lua_Integer timeout = luaL_checkinteger (L, 1); + if (timeout < 0) + luaL_argerror (L, 1, "timeout mustn't be negative"); + return lua_plugin_push_wait_timer (plugin, L, timeout); +} + +static int +lua_async_dial (lua_State *L) +{ + struct lua_plugin *plugin = lua_touserdata (L, lua_upvalueindex (1)); + return lua_plugin_push_wait_dial (plugin, L, + luaL_checkstring (L, 1), luaL_checkstring (L, 2)); +} + +static luaL_Reg lua_async_library[] = +{ + { "go", lua_async_go }, + { "timer_ms", lua_async_timer_ms }, + { "dial", lua_async_dial }, + { NULL, NULL }, +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +lua_plugin_get_screen_size (lua_State *L) +{ + lua_pushinteger (L, g_terminal.lines); + lua_pushinteger (L, g_terminal.columns); + return 2; +} + +static int +lua_plugin_measure (lua_State *L) +{ + struct lua_plugin *plugin = lua_touserdata (L, lua_upvalueindex (1)); + const char *line = lua_plugin_check_utf8 (L, 1); + + size_t term_len = 0, processed = 0, width = 0, len; + char *term = iconv_xstrdup (plugin->ctx->term_from_utf8, + (char *) line, strlen (line) + 1, &term_len); + + mbstate_t ps; + memset (&ps, 0, sizeof ps); + + wchar_t wch; + while ((len = mbrtowc (&wch, term + processed, term_len - processed, &ps))) + { + hard_assert (len != (size_t) -2 && len != (size_t) -1); + hard_assert ((processed += len) <= term_len); + + int wch_width = wcwidth (wch); + width += MAX (0, wch_width); + } + free (term); + lua_pushinteger (L, width); + return 1; +} + +static int +lua_ctx_gc (lua_State *L) +{ + return lua_weak_gc (L, &lua_ctx_info); +} + +static luaL_Reg lua_plugin_library[] = +{ + // These are pseudo-global functions: + + { "measure", lua_plugin_measure }, + { "parse", lua_plugin_parse }, + { "hook_input", lua_plugin_hook_input }, + { "hook_irc", lua_plugin_hook_irc }, + { "hook_prompt", lua_plugin_hook_prompt }, + { "hook_completion", lua_plugin_hook_completion }, + { "setup_config", lua_plugin_setup_config }, + + // And these are methods: + + // Note that this only returns the height when used through an accessor. + { "get_screen_size", lua_plugin_get_screen_size }, + { "__gc", lua_ctx_gc }, + { NULL, NULL }, +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void * +lua_plugin_alloc (void *ud, void *ptr, size_t o_size, size_t n_size) +{ + (void) ud; + (void) o_size; + + if (n_size) + return realloc (ptr, n_size); + + free (ptr); + return NULL; +} + +static int +lua_plugin_panic (lua_State *L) +{ + // XXX: we might be able to do something better + print_fatal ("Lua panicked: %s", lua_tostring (L, -1)); + lua_close (L); + exit (EXIT_FAILURE); + return 0; +} + +static void +lua_plugin_push_ref (lua_State *L, struct lua_plugin *self, void *object, + struct ispect_field *field) +{ + // We create a mapping on object type registration + hard_assert (lua_rawgetp (L, LUA_REGISTRYINDEX, field->fields)); + struct lua_weak_info *info = lua_touserdata (L, -1); + lua_pop (L, 1); + + if (!field->is_list) + { + lua_weak_push (L, self, object, info); + return; + } + + // As a rule in this codebase, these fields are right at the top of structs + struct list_header { LIST_HEADER (void) }; + + int i = 1; + lua_newtable (L); + LIST_FOR_EACH (struct list_header, iter, object) + { + lua_weak_push (L, self, iter, info); + lua_rawseti (L, -2, i++); + } +} + +static void lua_plugin_push_map_field (lua_State *L, struct lua_plugin *self, + const char *key, void *p, struct ispect_field *field); + +static void +lua_plugin_push_struct (lua_State *L, struct lua_plugin *self, + enum ispect_type type, void *value, struct ispect_field *field) +{ + if (type == ISPECT_STR) + { + const struct str *s = value; + lua_pushlstring (L, s->str, s->len); + return; + } + if (type == ISPECT_STR_MAP) + { + struct str_map_iter iter = str_map_iter_make (value); + + void *value; + lua_newtable (L); + while ((value = str_map_iter_next (&iter))) + lua_plugin_push_map_field (L, self, iter.link->key, value, field); + return; + } + hard_assert (!"unhandled introspection object type"); +} + +static void +lua_plugin_push_map_field (lua_State *L, struct lua_plugin *self, + const char *key, void *p, struct ispect_field *field) +{ + // That would mean maps in maps ad infinitum + hard_assert (field->subtype != ISPECT_STR_MAP); + + intptr_t n = (intptr_t) p; + switch (field->subtype) + { + // Here the types are generally casted to a void pointer + case ISPECT_BOOL: lua_pushboolean (L, (bool ) n); break; + case ISPECT_INT: lua_pushinteger (L, (int ) n); break; + case ISPECT_UINT: lua_pushinteger (L, (unsigned ) n); break; + case ISPECT_SIZE: lua_pushinteger (L, (size_t ) n); break; + case ISPECT_STRING: lua_pushstring (L, p); break; + case ISPECT_REF: lua_plugin_push_ref (L, self, p, field); break; + default: lua_plugin_push_struct (L, self, field->subtype, p, field); + } + lua_setfield (L, -2, key); +} + +static bool +lua_plugin_property_get_ispect (lua_State *L, const char *property_name) +{ + struct lua_weak_info *info = lua_touserdata (L, lua_upvalueindex (1)); + if (!info || !info->ispect) + return false; + + struct lua_weak *weak = luaL_checkudata (L, 1, info->name); + // TODO: I think we can do better than this, maybe binary search at least? + struct ispect_field *field; + for (field = info->ispect; field->name; field++) + if (!strcmp (property_name, field->name)) + break; + if (!field->name) + return false; + + struct lua_plugin *self = weak->plugin; + void *p = (uint8_t *) weak->object + field->offset; + switch (field->type) + { + // Here the types are really what's under the pointer + case ISPECT_BOOL: lua_pushboolean (L, *(bool *) p); break; + case ISPECT_INT: lua_pushinteger (L, *(int *) p); break; + case ISPECT_UINT: lua_pushinteger (L, *(unsigned *) p); break; + case ISPECT_SIZE: lua_pushinteger (L, *(size_t *) p); break; + case ISPECT_STRING: lua_pushstring (L, *(char **) p); break; + case ISPECT_REF: lua_plugin_push_ref (L, self, *(void **) p, field); break; + default: lua_plugin_push_struct (L, self, field->type, p, field); + } + return true; +} + +static int +lua_plugin_property_get (lua_State *L) +{ + luaL_checktype (L, 1, LUA_TUSERDATA); + const char *property_name = luaL_checkstring (L, 2); + + // Either it's directly present in the metatable + if (luaL_getmetafield (L, 1, property_name)) + return 1; + + // Or we try to find and eventually call a getter method + char *getter_name = xstrdup_printf ("get_%s", property_name); + bool found = luaL_getmetafield (L, 1, getter_name); + free (getter_name); + + if (found) + { + lua_pushvalue (L, 1); + lua_call (L, 1, 1); + return 1; + } + + // Maybe we can find it via introspection + if (lua_plugin_property_get_ispect (L, property_name)) + return 1; + + // Or we look for a property set by the user (__gc cannot be overriden) + if (lua_getuservalue (L, 1) != LUA_TTABLE) + lua_pushnil (L); + else + lua_getfield (L, -1, property_name); + return 1; +} + +static int +lua_plugin_property_set (lua_State *L) +{ + luaL_checktype (L, 1, LUA_TUSERDATA); + const char *property_name = luaL_checkstring (L, 2); + luaL_checkany (L, 3); + + // We use the associated value to store user-defined properties + int type = lua_getuservalue (L, 1); + if (type == LUA_TNIL) + { + lua_pop (L, 1); + lua_newtable (L); + lua_pushvalue (L, -1); + lua_setuservalue (L, 1); + } + else if (type != LUA_TTABLE) + return luaL_error (L, "associated value is not a table"); + + // Beware that we do not check for conflicts here; + // if Lua code writes a conflicting field, it is effectively ignored + lua_pushvalue (L, 3); + lua_setfield (L, -2, property_name); + return 0; +} + +static void +lua_plugin_add_accessors (lua_State *L, struct lua_weak_info *info) +{ + // Emulate properties for convenience + lua_pushlightuserdata (L, info); + lua_pushcclosure (L, lua_plugin_property_get, 1); + lua_setfield (L, -2, "__index"); + lua_pushcfunction (L, lua_plugin_property_set); + lua_setfield (L, -2, "__newindex"); +} + +static void +lua_plugin_reg_meta (lua_State *L, const char *name, luaL_Reg *fns) +{ + luaL_newmetatable (L, name); + luaL_setfuncs (L, fns, 0); + lua_plugin_add_accessors (L, NULL); + lua_pop (L, 1); +} + +static void +lua_plugin_reg_weak (lua_State *L, struct lua_weak_info *info, luaL_Reg *fns) +{ + // Create a mapping from the object type (info->ispect) back to metadata + // so that we can figure out what to create from ISPECT_REF fields + lua_pushlightuserdata (L, info); + lua_rawsetp (L, LUA_REGISTRYINDEX, info->ispect); + + luaL_newmetatable (L, info->name); + luaL_setfuncs (L, fns, 0); + lua_plugin_add_accessors (L, info); + lua_pop (L, 1); +} + +static struct plugin * +lua_plugin_load (struct app_context *ctx, const char *filename, + struct error **e) +{ + lua_State *L = lua_newstate (lua_plugin_alloc, NULL); + if (!L) + { + error_set (e, "Lua initialization failed"); + return NULL; + } + + lua_atpanic (L, lua_plugin_panic); + luaL_openlibs (L); + + struct lua_plugin *plugin = xcalloc (1, sizeof *plugin); + plugin->super.name = xstrdup (filename); + plugin->super.vtable = &lua_plugin_vtable; + plugin->ctx = ctx; + plugin->L = L; + + luaL_checkversion (L); + + // Register the xC library as a singleton with "plugin" as an upvalue + // (mostly historical, but rather convenient) + luaL_newmetatable (L, lua_ctx_info.name); + lua_pushlightuserdata (L, plugin); + luaL_setfuncs (L, lua_plugin_library, 1); + lua_plugin_add_accessors (L, &lua_ctx_info); + + // Add the asynchronous library underneath + lua_newtable (L); + lua_pushlightuserdata (L, plugin); + luaL_setfuncs (L, lua_async_library, 1); + lua_setfield (L, -2, "async"); + lua_pop (L, 1); + + lua_weak_push (L, plugin, ctx, &lua_ctx_info); + lua_setglobal (L, lua_ctx_info.name); + + // Create metatables for our objects + lua_plugin_reg_meta (L, XLUA_HOOK_METATABLE, lua_hook_table); + lua_plugin_reg_weak (L, &lua_user_info, lua_user_table); + lua_plugin_reg_weak (L, &lua_channel_info, lua_channel_table); + lua_plugin_reg_weak (L, &lua_buffer_info, lua_buffer_table); + lua_plugin_reg_weak (L, &lua_server_info, lua_server_table); + lua_plugin_reg_meta (L, XLUA_SCHEMA_METATABLE, lua_schema_table); + lua_plugin_reg_meta (L, XLUA_CONNECTION_METATABLE, lua_connection_table); + + lua_plugin_reg_meta (L, XLUA_TASK_METATABLE, lua_task_table); + lua_plugin_reg_meta (L, XLUA_WCHANNEL_METATABLE, lua_wchannel_table); + + struct error *error = NULL; + if (luaL_loadfile (L, filename)) + error_set (e, "%s: %s", "Lua", lua_tostring (L, -1)); + else if (!lua_plugin_call (plugin, 0, 0, &error)) + { + error_set (e, "%s: %s", "Lua", error->message); + error_free (error); + } + else + return &plugin->super; + + plugin_destroy (&plugin->super); + return NULL; +} + +#endif // HAVE_LUA + +// --- Plugins ----------------------------------------------------------------- + +typedef struct plugin *(*plugin_load_fn) + (struct app_context *ctx, const char *filename, struct error **e); + +// We can potentially add support for other scripting languages if so desired, +// however this possibility is just a byproduct of abstraction +static plugin_load_fn g_plugin_loaders[] = +{ +#ifdef HAVE_LUA + lua_plugin_load, +#endif // HAVE_LUA +}; + +static struct plugin * +plugin_load_from_filename (struct app_context *ctx, const char *filename, + struct error **e) +{ + struct plugin *plugin = NULL; + struct error *error = NULL; + for (size_t i = 0; i < N_ELEMENTS (g_plugin_loaders); i++) + if ((plugin = g_plugin_loaders[i](ctx, filename, &error)) || error) + break; + + if (error) + error_propagate (e, error); + else if (!plugin) + { + error_set (e, "no plugin handler for \"%s\"", filename); + return NULL; + } + return plugin; +} + +static struct plugin * +plugin_find (struct app_context *ctx, const char *name) +{ + LIST_FOR_EACH (struct plugin, iter, ctx->plugins) + if (!strcmp (name, iter->name)) + return iter; + return NULL; +} + +static char * +plugin_resolve_relative_filename (const char *filename) +{ + struct strv paths = strv_make (); + get_xdg_data_dirs (&paths); + strv_append (&paths, PROJECT_DATADIR); + char *result = resolve_relative_filename_generic + (&paths, PROGRAM_NAME "/plugins/", filename); + strv_free (&paths); + return result; +} + +static struct plugin * +plugin_load_by_name (struct app_context *ctx, const char *name, + struct error **e) +{ + struct plugin *plugin = plugin_find (ctx, name); + if (plugin) + { + error_set (e, "plugin already loaded"); + return NULL; + } + + // As a side effect, a plugin can be loaded multiple times by giving + // various relative or non-relative paths to the function; this is not + // supposed to be fool-proof though, that requires other mechanisms + char *filename = resolve_filename (name, plugin_resolve_relative_filename); + if (!filename) + { + error_set (e, "file not found"); + return NULL; + } + + plugin = plugin_load_from_filename (ctx, filename, e); + free (filename); + return plugin; +} + +static void +plugin_load (struct app_context *ctx, const char *name) +{ + struct error *e = NULL; + struct plugin *plugin = plugin_load_by_name (ctx, name, &e); + if (plugin) + { + // FIXME: this way the real name isn't available to the plugin on load, + // which has effect on e.g. plugin_config_name() + cstr_set (&plugin->name, xstrdup (name)); + + log_global_status (ctx, "Plugin \"#s\" loaded", name); + LIST_PREPEND (ctx->plugins, plugin); + } + else + { + log_global_error (ctx, "Can't load plugin \"#s\": #s", + name, e->message); + error_free (e); + } +} + +static void +plugin_unload (struct app_context *ctx, const char *name) +{ + struct plugin *plugin = plugin_find (ctx, name); + if (!plugin) + log_global_error (ctx, "Can't unload plugin \"#s\": #s", + name, "plugin not loaded"); + else + { + log_global_status (ctx, "Plugin \"#s\" unloaded", name); + LIST_UNLINK (ctx->plugins, plugin); + plugin_destroy (plugin); + } +} + +static void +load_plugins (struct app_context *ctx) +{ + const char *plugins = + get_config_string (ctx->config.root, "general.plugin_autoload"); + if (plugins) + { + struct strv v = strv_make (); + cstr_split (plugins, ",", true, &v); + for (size_t i = 0; i < v.len; i++) + plugin_load (ctx, v.vector[i]); + strv_free (&v); + } +} + +// --- User input handling ----------------------------------------------------- + +// HANDLER_NEEDS_REG is primarily for message sending commands, +// as they may want to log buffer lines and use our current nickname + +enum handler_flags +{ + HANDLER_SERVER = (1 << 0), ///< Server context required + HANDLER_NEEDS_REG = (1 << 1), ///< Server registration required + HANDLER_CHANNEL_FIRST = (1 << 2), ///< Channel required, first argument + HANDLER_CHANNEL_LAST = (1 << 3) ///< Channel required, last argument +}; + +struct handler_args +{ + struct app_context *ctx; ///< Application context + struct buffer *buffer; ///< Current buffer + struct server *s; ///< Related server + const char *channel_name; ///< Related channel name + char *arguments; ///< Command arguments +}; + +/// Cuts the longest non-whitespace portion of text and advances the pointer +static char * +cut_word (char **s) +{ + char *start = *s; + size_t word_len = strcspn (*s, WORD_BREAKING_CHARS); + char *end = start + word_len; + *s = end + strspn (end, WORD_BREAKING_CHARS); + *end = '\0'; + return start; +} + +/// Validates a word to be cut from a string +typedef bool (*word_validator_fn) (void *, char *); + +static char * +maybe_cut_word (char **s, word_validator_fn validator, void *user_data) +{ + char *start = *s; + size_t word_len = strcspn (*s, WORD_BREAKING_CHARS); + + char *word = xstrndup (start, word_len); + bool ok = validator (user_data, word); + free (word); + + if (!ok) + return NULL; + + char *end = start + word_len; + *s = end + strspn (end, WORD_BREAKING_CHARS); + *end = '\0'; + return start; +} + +static char * +maybe_cut_word_from_end (char **s, word_validator_fn validator, void *user_data) +{ + // Find the start and end of the last word + // Contrary to maybe_cut_word(), we ignore all whitespace at the end + char *start = *s, *end = start + strlen (start); + while (end > start && strchr (WORD_BREAKING_CHARS, end [-1])) + end--; + char *word = end; + while (word > start && !strchr (WORD_BREAKING_CHARS, word[-1])) + word--; + + // There's just one word at maximum, starting at the beginning + if (word == start) + return maybe_cut_word (s, validator, user_data); + + char *tmp = xstrndup (word, word - start); + bool ok = validator (user_data, tmp); + free (tmp); + + if (!ok) + return NULL; + + // It doesn't start at the beginning, cut it off and return it + word[-1] = *end = '\0'; + return word; +} + +static bool +validate_channel_name (void *user_data, char *word) +{ + return irc_is_channel (user_data, word); +} + +static char * +try_get_channel (struct handler_args *a, + char *(*cutter) (char **, word_validator_fn, void *)) +{ + char *channel_name = cutter (&a->arguments, validate_channel_name, a->s); + if (channel_name) + return channel_name; + if (a->buffer->type == BUFFER_CHANNEL) + return a->buffer->channel->name; + return NULL; +} + +static bool +try_handle_buffer_goto (struct app_context *ctx, const char *word) +{ + unsigned long n; + if (!xstrtoul (&n, word, 10)) + return false; + + if (n > INT_MAX || !buffer_goto (ctx, n)) + log_global_error (ctx, "#s: #s", "No such buffer", word); + return true; +} + +static struct buffer * +try_decode_buffer (struct app_context *ctx, const char *word) +{ + unsigned long n; + struct buffer *buffer = NULL; + if (xstrtoul (&n, word, 10) && n <= INT_MAX) + buffer = buffer_at_index (ctx, n); + if (buffer || (buffer = buffer_by_name (ctx, word))) + return buffer; + + // Basic case insensitive partial matching -- at most one buffer can match + int n_matches = 0; + LIST_FOR_EACH (struct buffer, iter, ctx->buffers) + { + char *string = xstrdup (iter->name); + char *pattern = xstrdup_printf ("*%s*", word); + for (char *p = string; *p; p++) *p = tolower_ascii (*p); + for (char *p = pattern; *p; p++) *p = tolower_ascii (*p); + if (!fnmatch (pattern, string, 0)) + { + n_matches++; + buffer = iter; + } + free (string); + free (pattern); + } + return n_matches == 1 ? buffer : NULL; +} + +static void +show_buffers_list (struct app_context *ctx) +{ + log_global_indent (ctx, ""); + log_global_indent (ctx, "Buffers list:"); + + int i = 1; + LIST_FOR_EACH (struct buffer, iter, ctx->buffers) + { + struct str s = str_make (); + int new = iter->new_messages_count - iter->new_unimportant_count; + if (new && iter != ctx->current_buffer) + str_append_printf (&s, " (%d%s)", new, &"!"[!iter->highlighted]); + log_global_indent (ctx, + " [#d] #s#&s", i++, iter->name, str_steal (&s)); + } +} + +static void +part_channel (struct server *s, const char *channel_name, const char *reason) +{ + if (*reason) + irc_send (s, "PART %s :%s", channel_name, reason); + else + irc_send (s, "PART %s", channel_name); + + struct channel *channel; + if ((channel = str_map_find (&s->irc_channels, channel_name))) + channel->left_manually = true; +} + +static bool +handle_buffer_goto (struct app_context *ctx, struct handler_args *a) +{ + if (!*a->arguments) + return false; + + const char *which = cut_word (&a->arguments); + struct buffer *buffer = try_decode_buffer (ctx, which); + if (buffer) + buffer_activate (ctx, buffer); + else + log_global_error (ctx, "#s: #s", "No such buffer", which); + return true; +} + +static void +handle_buffer_close (struct app_context *ctx, struct handler_args *a) +{ + struct buffer *buffer = NULL; + const char *which = NULL; + if (!*a->arguments) + buffer = a->buffer; + else + buffer = try_decode_buffer (ctx, (which = cut_word (&a->arguments))); + + if (!buffer) + log_global_error (ctx, "#s: #s", "No such buffer", which); + else if (buffer == ctx->global_buffer) + log_global_error (ctx, "Can't close the global buffer"); + else if (buffer->type == BUFFER_SERVER) + log_global_error (ctx, "Can't close a server buffer"); + else + { + // The user would be unable to recreate the buffer otherwise + if (buffer->type == BUFFER_CHANNEL + && irc_channel_is_joined (buffer->channel)) + part_channel (buffer->server, buffer->channel->name, ""); + buffer_remove_safe (ctx, buffer); + } +} + +static bool +handle_buffer_move (struct app_context *ctx, struct handler_args *a) +{ + unsigned long request; + if (!xstrtoul (&request, a->arguments, 10)) + return false; + + if (request == 0 || request > (unsigned long) buffer_count (ctx)) + { + log_global_error (ctx, "#s: #s", + "Can't move buffer", "requested position is out of range"); + return true; + } + buffer_move (ctx, a->buffer, request); + return true; +} + +static bool +handle_command_buffer (struct handler_args *a) +{ + struct app_context *ctx = a->ctx; + char *action = cut_word (&a->arguments); + if (try_handle_buffer_goto (ctx, action)) + return true; + + bool result = true; + if (!*action || !strcasecmp_ascii (action, "list")) + show_buffers_list (ctx); + else if (!strcasecmp_ascii (action, "clear")) + { + buffer_clear (ctx, a->buffer); + if (a->buffer == ctx->current_buffer) + buffer_print_backlog (ctx, a->buffer); + } + else if (!strcasecmp_ascii (action, "move")) + result = handle_buffer_move (ctx, a); + else if (!strcasecmp_ascii (action, "goto")) + result = handle_buffer_goto (ctx, a); + else if (!strcasecmp_ascii (action, "close")) + handle_buffer_close (ctx, a); + else + result = false; + return result; +} + +static bool +handle_command_set_add + (struct strv *items, const struct strv *values, struct error **e) +{ + for (size_t i = 0; i < values->len; i++) + { + const char *value = values->vector[i]; + if (strv_find (items, values->vector[i]) != -1) + return error_set (e, "already present in the array: %s", value); + strv_append (items, value); + } + return true; +} + +static bool +handle_command_set_remove + (struct strv *items, const struct strv *values, struct error **e) +{ + for (size_t i = 0; i < values->len; i++) + { + const char *value = values->vector[i]; + ssize_t i = strv_find (items, value); + if (i == -1) + return error_set (e, "not present in the array: %s", value); + strv_remove (items, i); + } + return true; +} + +static bool +handle_command_set_modify + (struct config_item *item, const char *value, bool add, struct error **e) +{ + struct strv items = strv_make (); + if (item->type != CONFIG_ITEM_NULL) + cstr_split (item->value.string.str, ",", false, &items); + if (items.len == 1 && !*items.vector[0]) + strv_reset (&items); + + struct strv values = strv_make (); + cstr_split (value, ",", false, &values); + bool result = add + ? handle_command_set_add (&items, &values, e) + : handle_command_set_remove (&items, &values, e); + + if (result) + { + char *changed = strv_join (&items, ","); + struct str tmp = { .str = changed, .len = strlen (changed) }; + result = config_item_set_from (item, + config_item_string_array (&tmp), e); + free (changed); + } + + strv_free (&items); + strv_free (&values); + return result; +} + +static bool +handle_command_set_assign_item (struct app_context *ctx, + char *key, struct config_item *new_, bool add, bool remove) +{ + struct config_item *item = + config_item_get (ctx->config.root, key, NULL); + hard_assert (item); + + struct error *e = NULL; + if (!item->schema) + error_set (&e, "option not recognized"); + else if (!add && !remove) + config_item_set_from (item, config_item_clone (new_), &e); + else if (item->schema->type != CONFIG_ITEM_STRING_ARRAY) + error_set (&e, "not a string array"); + else + handle_command_set_modify (item, new_->value.string.str, add, &e); + + if (e) + { + log_global_error (ctx, + "Failed to set option \"#s\": #s", key, e->message); + error_free (e); + return false; + } + + struct strv tmp = strv_make (); + dump_matching_options (ctx->config.root, key, &tmp); + log_global_status (ctx, "Option changed: #s", tmp.vector[0]); + strv_free (&tmp); + return true; +} + +static bool +handle_command_set_assign + (struct app_context *ctx, struct strv *all, char *arguments) +{ + hard_assert (all->len > 0); + + char *op = cut_word (&arguments); + bool add = false; + bool remove = false; + + if (!strcmp (op, "+=")) add = true; + else if (!strcmp (op, "-=")) remove = true; + else if (strcmp (op, "=")) return false; + + if (!*arguments) + return false; + + struct error *e = NULL; + struct config_item *new_ = + config_item_parse (arguments, strlen (arguments), true, &e); + if (e) + { + log_global_error (ctx, "Invalid value: #s", e->message); + error_free (e); + return true; + } + + if ((add | remove) && !config_item_type_is_string (new_->type)) + { + log_global_error (ctx, "+= / -= operators need a string argument"); + config_item_destroy (new_); + return true; + } + + bool changed = false; + for (size_t i = 0; i < all->len; i++) + { + char *key = cstr_cut_until (all->vector[i], " "); + if (handle_command_set_assign_item (ctx, key, new_, add, remove)) + changed = true; + free (key); + } + config_item_destroy (new_); + + if (changed && get_config_boolean (ctx->config.root, "general.autosave")) + save_configuration (ctx); + return true; +} + +static bool +handle_command_set (struct handler_args *a) +{ + struct app_context *ctx = a->ctx; + char *option = "*"; + if (*a->arguments) + option = cut_word (&a->arguments); + + struct strv all = strv_make (); + dump_matching_options (ctx->config.root, option, &all); + + bool result = true; + if (!all.len) + log_global_error (ctx, "No matches: #s", option); + else if (!*a->arguments) + { + log_global_indent (ctx, ""); + for (size_t i = 0; i < all.len; i++) + log_global_indent (ctx, "#s", all.vector[i]); + } + else + result = handle_command_set_assign (ctx, &all, a->arguments); + + strv_free (&all); + return result; +} + +static bool +handle_command_save (struct handler_args *a) +{ + if (*a->arguments) + return false; + + save_configuration (a->ctx); + return true; +} + +static void +show_plugin_list (struct app_context *ctx) +{ + log_global_indent (ctx, ""); + log_global_indent (ctx, "Plugins:"); + LIST_FOR_EACH (struct plugin, iter, ctx->plugins) + log_global_indent (ctx, " #s", iter->name); +} + +static bool +handle_command_plugin (struct handler_args *a) +{ + char *action = cut_word (&a->arguments); + if (!*action || !strcasecmp_ascii (action, "list")) + show_plugin_list (a->ctx); + else if (!strcasecmp_ascii (action, "load")) + { + if (!*a->arguments) + return false; + + plugin_load (a->ctx, cut_word (&a->arguments)); + } + else if (!strcasecmp_ascii (action, "unload")) + { + if (!*a->arguments) + return false; + + plugin_unload (a->ctx, cut_word (&a->arguments)); + } + else + return false; + return true; +} + +static bool +handle_command_relay (struct handler_args *a) +{ + if (*a->arguments) + return false; + + int len = 0; + LIST_FOR_EACH (struct client, c, a->ctx->clients) + len++; + + if (a->ctx->relay_fd == -1) + log_global_status (a->ctx, "The relay is not enabled"); + else + log_global_status (a->ctx, "The relay has #d clients", len); + return true; +} + +static bool +show_aliases_list (struct app_context *ctx) +{ + log_global_indent (ctx, ""); + log_global_indent (ctx, "Aliases:"); + + struct str_map *aliases = get_aliases_config (ctx); + if (!aliases->len) + { + log_global_indent (ctx, " (none)"); + return true; + } + + struct str_map_iter iter = str_map_iter_make (aliases); + struct config_item *alias; + while ((alias = str_map_iter_next (&iter))) + { + struct str definition = str_make (); + if (config_item_type_is_string (alias->type)) + config_item_write_string (&definition, &alias->value.string); + else + str_append (&definition, "alias definition is not a string"); + log_global_indent (ctx, " /#s: #s", iter.link->key, definition.str); + str_free (&definition); + } + return true; +} + +static bool +handle_command_alias (struct handler_args *a) +{ + if (!*a->arguments) + return show_aliases_list (a->ctx); + + char *name = cut_word (&a->arguments); + if (!*a->arguments) + return false; + if (*name == '/') + name++; + + struct config_item *alias = config_item_string_from_cstr (a->arguments); + + struct str definition = str_make (); + config_item_write_string (&definition, &alias->value.string); + str_map_set (get_aliases_config (a->ctx), name, alias); + log_global_status (a->ctx, "Created alias /#s: #s", name, definition.str); + str_free (&definition); + return true; +} + +static bool +handle_command_unalias (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + struct str_map *aliases = get_aliases_config (a->ctx); + while (*a->arguments) + { + char *name = cut_word (&a->arguments); + if (!str_map_find (aliases, name)) + log_global_error (a->ctx, "No such alias: #s", name); + else + { + str_map_set (aliases, name, NULL); + log_global_status (a->ctx, "Alias removed: #s", name); + } + } + return true; +} + +static bool +handle_command_msg (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + char *target = cut_word (&a->arguments); + if (!*a->arguments) + log_server_error (a->s, a->s->buffer, "No text to send"); + else + SEND_AUTOSPLIT_PRIVMSG (a->s, target, a->arguments); + return true; +} + +static bool +handle_command_query (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + char *target = cut_word (&a->arguments); + if (irc_is_channel (a->s, irc_skip_statusmsg (a->s, target))) + log_server_error (a->s, a->s->buffer, "Cannot query a channel"); + else if (!*a->arguments) + buffer_activate (a->ctx, irc_get_or_make_user_buffer (a->s, target)); + else + { + buffer_activate (a->ctx, irc_get_or_make_user_buffer (a->s, target)); + SEND_AUTOSPLIT_PRIVMSG (a->s, target, a->arguments); + } + return true; +} + +static bool +handle_command_notice (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + char *target = cut_word (&a->arguments); + if (!*a->arguments) + log_server_error (a->s, a->s->buffer, "No text to send"); + else + SEND_AUTOSPLIT_NOTICE (a->s, target, a->arguments); + return true; +} + +static bool +handle_command_squery (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + char *target = cut_word (&a->arguments); + if (!*a->arguments) + log_server_error (a->s, a->s->buffer, "No text to send"); + else + irc_send (a->s, "SQUERY %s :%s", target, a->arguments); + return true; +} + +static bool +handle_command_ctcp (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + char *target = cut_word (&a->arguments); + if (!*a->arguments) + return false; + + char *tag = cut_word (&a->arguments); + cstr_transform (tag, toupper_ascii); + + if (*a->arguments) + irc_send (a->s, "PRIVMSG %s :\x01%s %s\x01", target, tag, a->arguments); + else + irc_send (a->s, "PRIVMSG %s :\x01%s\x01", target, tag); + return true; +} + +static bool +handle_command_me (struct handler_args *a) +{ + if (a->buffer->type == BUFFER_CHANNEL) + SEND_AUTOSPLIT_ACTION (a->s, + a->buffer->channel->name, a->arguments); + else if (a->buffer->type == BUFFER_PM) + SEND_AUTOSPLIT_ACTION (a->s, + a->buffer->user->nickname, a->arguments); + else + log_server_error (a->s, a->s->buffer, + "Can't do this from a server buffer (#s)", + "send CTCP actions"); + return true; +} + +static bool +handle_command_quit (struct handler_args *a) +{ + request_quit (a->ctx, *a->arguments ? a->arguments : NULL); + return true; +} + +static bool +handle_command_join (struct handler_args *a) +{ + // XXX: send the last known channel key? + if (irc_is_channel (a->s, a->arguments)) + // XXX: we may want to split the list of channels + irc_send (a->s, "JOIN %s", a->arguments); + else if (a->buffer->type != BUFFER_CHANNEL) + log_server_error (a->s, a->buffer, "#s: #s", "Can't join", + "no channel name given and this buffer is not a channel"); + else if (irc_channel_is_joined (a->buffer->channel)) + log_server_error (a->s, a->buffer, "#s: #s", "Can't join", + "you already are on the channel"); + else if (*a->arguments) + irc_send (a->s, "JOIN %s :%s", a->buffer->channel->name, a->arguments); + else + irc_send (a->s, "JOIN %s", a->buffer->channel->name); + return true; +} + +static bool +handle_command_part (struct handler_args *a) +{ + if (irc_is_channel (a->s, a->arguments)) + { + struct strv v = strv_make (); + cstr_split (cut_word (&a->arguments), ",", true, &v); + for (size_t i = 0; i < v.len; i++) + part_channel (a->s, v.vector[i], a->arguments); + strv_free (&v); + } + else if (a->buffer->type != BUFFER_CHANNEL) + log_server_error (a->s, a->buffer, "#s: #s", "Can't part", + "no channel name given and this buffer is not a channel"); + else if (!irc_channel_is_joined (a->buffer->channel)) + log_server_error (a->s, a->buffer, "#s: #s", "Can't part", + "you're not on the channel"); + else + part_channel (a->s, a->buffer->channel->name, a->arguments); + return true; +} + +static void +cycle_channel (struct server *s, const char *channel_name, const char *reason) +{ + // If a channel key is set, we must specify it when rejoining + const char *key = NULL; + struct channel *channel; + if ((channel = str_map_find (&s->irc_channels, channel_name))) + key = str_map_find (&channel->param_modes, "k"); + + if (*reason) + irc_send (s, "PART %s :%s", channel_name, reason); + else + irc_send (s, "PART %s", channel_name); + + if (key) + irc_send (s, "JOIN %s :%s", channel_name, key); + else + irc_send (s, "JOIN %s", channel_name); +} + +static bool +handle_command_cycle (struct handler_args *a) +{ + if (irc_is_channel (a->s, a->arguments)) + { + struct strv v = strv_make (); + cstr_split (cut_word (&a->arguments), ",", true, &v); + for (size_t i = 0; i < v.len; i++) + cycle_channel (a->s, v.vector[i], a->arguments); + strv_free (&v); + } + else if (a->buffer->type != BUFFER_CHANNEL) + log_server_error (a->s, a->buffer, "#s: #s", "Can't cycle", + "no channel name given and this buffer is not a channel"); + else if (!irc_channel_is_joined (a->buffer->channel)) + log_server_error (a->s, a->buffer, "#s: #s", "Can't cycle", + "you're not on the channel"); + else + cycle_channel (a->s, a->buffer->channel->name, a->arguments); + return true; +} + +static bool +handle_command_mode (struct handler_args *a) +{ + // Channel names prefixed by "+" collide with mode strings, + // so we just disallow specifying these channels + char *target = NULL; + if (strchr ("+-\0", *a->arguments)) + { + if (a->buffer->type == BUFFER_CHANNEL) + target = a->buffer->channel->name; + if (a->buffer->type == BUFFER_PM) + target = a->buffer->user->nickname; + if (a->buffer->type == BUFFER_SERVER) + target = a->s->irc_user->nickname; + } + else + // If there a->arguments and they don't begin with a mode string, + // they're either a user name or a channel name + target = cut_word (&a->arguments); + + if (!target) + log_server_error (a->s, a->buffer, "#s: #s", "Can't change mode", + "no target given and this buffer is neither a PM nor a channel"); + else if (*a->arguments) + // XXX: split channel mode params as necessary using irc_max_modes? + irc_send (a->s, "MODE %s %s", target, a->arguments); + else + irc_send (a->s, "MODE %s", target); + return true; +} + +static bool +handle_command_topic (struct handler_args *a) +{ + if (*a->arguments) + // FIXME: there's no way to start the topic with whitespace + // FIXME: there's no way to unset the topic; + // we could adopt the Tcl style of "-switches" with "--" sentinels, + // or we could accept "strings" in the config format + irc_send (a->s, "TOPIC %s :%s", a->channel_name, a->arguments); + else + irc_send (a->s, "TOPIC %s", a->channel_name); + return true; +} + +static bool +handle_command_kick (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + char *target = cut_word (&a->arguments); + if (*a->arguments) + irc_send (a->s, "KICK %s %s :%s", + a->channel_name, target, a->arguments); + else + irc_send (a->s, "KICK %s %s", a->channel_name, target); + return true; +} + +static bool +handle_command_kickban (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + char *target = cut_word (&a->arguments); + if (strpbrk (target, "!@*?")) + return false; + + // XXX: how about other masks? + irc_send (a->s, "MODE %s +b %s!*@*", a->channel_name, target); + if (*a->arguments) + irc_send (a->s, "KICK %s %s :%s", + a->channel_name, target, a->arguments); + else + irc_send (a->s, "KICK %s %s", a->channel_name, target); + return true; +} + +static void +mass_channel_mode (struct server *s, const char *channel_name, + bool adding, char mode_char, struct strv *v) +{ + size_t n; + for (size_t i = 0; i < v->len; i += n) + { + struct str modes = str_make (); + struct str params = str_make (); + + n = MIN (v->len - i, s->irc_max_modes); + str_append_printf (&modes, "MODE %s %c", channel_name, "-+"[adding]); + for (size_t k = 0; k < n; k++) + { + str_append_c (&modes, mode_char); + str_append_printf (¶ms, " %s", v->vector[i + k]); + } + + irc_send (s, "%s%s", modes.str, params.str); + + str_free (&modes); + str_free (¶ms); + } +} + +static void +mass_channel_mode_mask_list + (struct handler_args *a, bool adding, char mode_char) +{ + struct strv v = strv_make (); + cstr_split (a->arguments, " ", true, &v); + + // XXX: this may be a bit too trivial; we could also map nicknames + // to information from WHO polling or userhost-in-names + for (size_t i = 0; i < v.len; i++) + { + char *target = v.vector[i]; + if (strpbrk (target, "!@*?") || irc_is_extban (a->s, target)) + continue; + + v.vector[i] = xstrdup_printf ("%s!*@*", target); + free (target); + } + + mass_channel_mode (a->s, a->channel_name, adding, mode_char, &v); + strv_free (&v); +} + +static bool +handle_command_ban (struct handler_args *a) +{ + if (*a->arguments) + mass_channel_mode_mask_list (a, true, 'b'); + else + irc_send (a->s, "MODE %s +b", a->channel_name); + return true; +} + +static bool +handle_command_unban (struct handler_args *a) +{ + if (*a->arguments) + mass_channel_mode_mask_list (a, false, 'b'); + else + return false; + return true; +} + +static bool +handle_command_invite (struct handler_args *a) +{ + struct strv v = strv_make (); + cstr_split (a->arguments, " ", true, &v); + + bool result = !!v.len; + for (size_t i = 0; i < v.len; i++) + irc_send (a->s, "INVITE %s %s", v.vector[i], a->channel_name); + + strv_free (&v); + return result; +} + +static struct server * +resolve_server (struct app_context *ctx, struct handler_args *a, + const char *command_name) +{ + struct server *s = NULL; + if (*a->arguments) + { + char *server_name = cut_word (&a->arguments); + if (!(s = str_map_find (&ctx->servers, server_name))) + log_global_error (ctx, "/#s: #s: #s", + command_name, "no such server", server_name); + } + else if (a->buffer->type == BUFFER_GLOBAL) + log_global_error (ctx, "/#s: #s", + command_name, "no server name given and this buffer is global"); + else + s = a->buffer->server; + return s; +} + +static bool +handle_command_connect (struct handler_args *a) +{ + struct server *s = NULL; + if (!(s = resolve_server (a->ctx, a, "connect"))) + return true; + + if (irc_is_connected (s)) + { + log_server_error (s, s->buffer, "Already connected"); + return true; + } + if (s->state == IRC_CONNECTING) + irc_destroy_connector (s); + + irc_cancel_timers (s); + + s->reconnect_attempt = 0; + irc_initiate_connect (s); + return true; +} + +static bool +handle_command_disconnect (struct handler_args *a) +{ + struct server *s = NULL; + if (!(s = resolve_server (a->ctx, a, "disconnect"))) + return true; + + if (s->state == IRC_CONNECTING) + { + log_server_status (s, s->buffer, "Connecting aborted"); + irc_destroy_connector (s); + } + else if (poller_timer_is_active (&s->reconnect_tmr)) + { + log_server_status (s, s->buffer, "Connecting aborted"); + poller_timer_reset (&s->reconnect_tmr); + } + else if (!irc_is_connected (s)) + log_server_error (s, s->buffer, "Not connected"); + else + irc_initiate_disconnect (s, *a->arguments ? a->arguments : NULL); + return true; +} + +static bool +show_servers_list (struct app_context *ctx) +{ + log_global_indent (ctx, ""); + log_global_indent (ctx, "Servers list:"); + + struct str_map_iter iter = str_map_iter_make (&ctx->servers); + struct server *s; + while ((s = str_map_iter_next (&iter))) + log_global_indent (ctx, " #s", s->name); + return true; +} + +static bool +handle_server_add (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + struct app_context *ctx = a->ctx; + char *name = cut_word (&a->arguments); + const char *err; + if ((err = check_server_name_for_addition (ctx, name))) + log_global_error (ctx, "Cannot create server `#s': #s", name, err); + else + { + server_add_new (ctx, name); + log_global_status (ctx, "Server added: #s", name); + } + return true; +} + +static bool +handle_server_remove (struct handler_args *a) +{ + struct app_context *ctx = a->ctx; + struct server *s = NULL; + if (!(s = resolve_server (ctx, a, "server"))) + return true; + + if (irc_is_connected (s)) + log_server_error (s, s->buffer, "Can't remove a connected server"); + else + { + char *name = xstrdup (s->name); + server_remove (ctx, s); + log_global_status (ctx, "Server removed: #s", name); + free (name); + } + return true; +} + +static bool +handle_server_rename (struct handler_args *a) +{ + struct app_context *ctx = a->ctx; + if (!*a->arguments) + return false; + char *old_name = cut_word (&a->arguments); + if (!*a->arguments) + return false; + char *new_name = cut_word (&a->arguments); + + struct server *s; + const char *err; + if (!(s = str_map_find (&ctx->servers, old_name))) + log_global_error (ctx, "/#s: #s: #s", + "server", "no such server", old_name); + else if ((err = check_server_name_for_addition (ctx, new_name))) + log_global_error (ctx, + "Cannot rename server to `#s': #s", new_name, err); + else + { + server_rename (ctx, s, new_name); + log_global_status (ctx, "Server renamed: #s to #s", old_name, new_name); + } + return true; +} + +static bool +handle_command_server (struct handler_args *a) +{ + if (!*a->arguments) + return show_servers_list (a->ctx); + + char *action = cut_word (&a->arguments); + if (!strcasecmp_ascii (action, "list")) + return show_servers_list (a->ctx); + if (!strcasecmp_ascii (action, "add")) + return handle_server_add (a); + if (!strcasecmp_ascii (action, "remove")) + return handle_server_remove (a); + if (!strcasecmp_ascii (action, "rename")) + return handle_server_rename (a); + return false; +} + +static bool +handle_command_names (struct handler_args *a) +{ + char *channel_name = try_get_channel (a, maybe_cut_word); + if (channel_name) + irc_send (a->s, "NAMES %s", channel_name); + else + irc_send (a->s, "NAMES"); + return true; +} + +static bool +handle_command_whois (struct handler_args *a) +{ + if (*a->arguments) + irc_send (a->s, "WHOIS %s", a->arguments); + else if (a->buffer->type == BUFFER_PM) + irc_send (a->s, "WHOIS %s", a->buffer->user->nickname); + else if (a->buffer->type == BUFFER_SERVER) + irc_send (a->s, "WHOIS %s", a->s->irc_user->nickname); + else + log_server_error (a->s, a->buffer, "#s: #s", "Can't request info", + "no target given and this buffer is neither a PM nor a server"); + return true; +} + +static bool +handle_command_whowas (struct handler_args *a) +{ + if (*a->arguments) + irc_send (a->s, "WHOWAS %s", a->arguments); + else if (a->buffer->type == BUFFER_PM) + irc_send (a->s, "WHOWAS %s", a->buffer->user->nickname); + else + log_server_error (a->s, a->buffer, "#s: #s", "Can't request info", + "no target given and this buffer is not a PM"); + return true; +} + +static bool +handle_command_kill (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + char *target = cut_word (&a->arguments); + if (*a->arguments) + irc_send (a->s, "KILL %s :%s", target, a->arguments); + else + irc_send (a->s, "KILL %s", target); + return true; +} + +static bool +handle_command_nick (struct handler_args *a) +{ + if (!*a->arguments) + return false; + + irc_send (a->s, "NICK %s", cut_word (&a->arguments)); + return true; +} + +static bool +handle_command_quote (struct handler_args *a) +{ + irc_send (a->s, "%s", a->arguments); + return true; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +handle_command_channel_mode + (struct handler_args *a, bool adding, char mode_char) +{ + const char *targets = a->arguments; + if (!*targets) + { + if (adding) + return false; + + targets = a->s->irc_user->nickname; + } + + struct strv v = strv_make (); + cstr_split (targets, " ", true, &v); + mass_channel_mode (a->s, a->channel_name, adding, mode_char, &v); + strv_free (&v); + return true; +} + +#define CHANMODE_HANDLER(name, adding, mode_char) \ + static bool \ + handle_command_ ## name (struct handler_args *a) \ + { \ + return handle_command_channel_mode (a, (adding), (mode_char)); \ + } + +CHANMODE_HANDLER (op, true, 'o') CHANMODE_HANDLER (deop, false, 'o') +CHANMODE_HANDLER (voice, true, 'v') CHANMODE_HANDLER (devoice, false, 'v') + +#define TRIVIAL_HANDLER(name, command) \ + static bool \ + handle_command_ ## name (struct handler_args *a) \ + { \ + if (*a->arguments) \ + irc_send (a->s, command " %s", a->arguments); \ + else \ + irc_send (a->s, command); \ + return true; \ + } + +TRIVIAL_HANDLER (list, "LIST") +TRIVIAL_HANDLER (who, "WHO") +TRIVIAL_HANDLER (motd, "MOTD") +TRIVIAL_HANDLER (oper, "OPER") +TRIVIAL_HANDLER (stats, "STATS") +TRIVIAL_HANDLER (away, "AWAY") + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool handle_command_help (struct handler_args *); + +static struct command_handler +{ + const char *name; + const char *description; + const char *usage; + bool (*handler) (struct handler_args *a); + enum handler_flags flags; +} +g_command_handlers[] = +{ + { "help", "Show help", + "[<command> | <option>]", + handle_command_help, 0 }, + { "quit", "Quit the program", + "[<message>]", + handle_command_quit, 0 }, + { "buffer", "Manage buffers", + "<N> | list | clear | move <N> | goto <N or name> | close [<N or name>]", + handle_command_buffer, 0 }, + { "set", "Manage configuration", + "[<option>]", + handle_command_set, 0 }, + { "save", "Save configuration", + NULL, + handle_command_save, 0 }, + { "plugin", "Manage plugins", + "list | load <name> | unload <name>", + handle_command_plugin, 0 }, + { "relay", "Show relay information", + NULL, + handle_command_relay, 0 }, + + { "alias", "List or set aliases", + "[<name> <definition>]", + handle_command_alias, 0 }, + { "unalias", "Unset aliases", + "<name>...", + handle_command_unalias, 0 }, + + { "msg", "Send message to a nick or channel", + "<target> <message>", + handle_command_msg, HANDLER_SERVER | HANDLER_NEEDS_REG }, + { "query", "Send a private message to a nick", + "<nick> <message>", + handle_command_query, HANDLER_SERVER | HANDLER_NEEDS_REG }, + { "notice", "Send notice to a nick or channel", + "<target> <message>", + handle_command_notice, HANDLER_SERVER | HANDLER_NEEDS_REG }, + { "squery", "Send a message to a service", + "<service> <message>", + handle_command_squery, HANDLER_SERVER | HANDLER_NEEDS_REG }, + { "ctcp", "Send a CTCP query", + "<target> <tag>", + handle_command_ctcp, HANDLER_SERVER | HANDLER_NEEDS_REG }, + { "me", "Send a CTCP action", + "<message>", + handle_command_me, HANDLER_SERVER | HANDLER_NEEDS_REG }, + + { "join", "Join channels", + "[<channel>[,<channel>...]] [<key>[,<key>...]]", + handle_command_join, HANDLER_SERVER }, + { "part", "Leave channels", + "[<channel>[,<channel>...]] [<reason>]", + handle_command_part, HANDLER_SERVER }, + { "cycle", "Rejoin channels", + "[<channel>[,<channel>...]] [<reason>]", + handle_command_cycle, HANDLER_SERVER }, + + { "op", "Give channel operator status", + "<nick>...", + handle_command_op, HANDLER_SERVER | HANDLER_CHANNEL_FIRST }, + { "deop", "Remove channel operator status", + "[<nick>...]", + handle_command_deop, HANDLER_SERVER | HANDLER_CHANNEL_FIRST }, + { "voice", "Give voice", + "<nick>...", + handle_command_voice, HANDLER_SERVER | HANDLER_CHANNEL_FIRST }, + { "devoice", "Remove voice", + "[<nick>...]", + handle_command_devoice, HANDLER_SERVER | HANDLER_CHANNEL_FIRST }, + + { "mode", "Change mode", + "[<channel>] [<mode>...]", + handle_command_mode, HANDLER_SERVER }, + { "topic", "Change topic", + "[<channel>] [<topic>]", + handle_command_topic, HANDLER_SERVER | HANDLER_CHANNEL_FIRST }, + { "kick", "Kick user from channel", + "[<channel>] <user> [<reason>]", + handle_command_kick, HANDLER_SERVER | HANDLER_CHANNEL_FIRST }, + { "kickban", "Kick and ban user from channel", + "[<channel>] <user> [<reason>]", + handle_command_kickban, HANDLER_SERVER | HANDLER_CHANNEL_FIRST }, + { "ban", "Ban user from channel", + "[<channel>] [<mask>...]", + handle_command_ban, HANDLER_SERVER | HANDLER_CHANNEL_FIRST }, + { "unban", "Unban user from channel", + "[<channel>] <mask>...", + handle_command_unban, HANDLER_SERVER | HANDLER_CHANNEL_FIRST }, + { "invite", "Invite user to channel", + "<user>... [<channel>]", + handle_command_invite, HANDLER_SERVER | HANDLER_CHANNEL_LAST }, + + { "server", "Manage servers", + "list | add <name> | remove <name> | rename <old> <new>", + handle_command_server, 0 }, + { "connect", "Connect to the server", + "[<server>]", + handle_command_connect, 0 }, + { "disconnect", "Disconnect from the server", + "[<server> [<reason>]]", + handle_command_disconnect, 0 }, + + { "list", "List channels and their topic", + "[<channel>[,<channel>...]] [<target>]", + handle_command_list, HANDLER_SERVER }, + { "names", "List users on channel", + "[<channel>[,<channel>...]]", + handle_command_names, HANDLER_SERVER }, + { "who", "List users", + "[<mask> [o]]", + handle_command_who, HANDLER_SERVER }, + { "whois", "Get user information", + "[<target>] <mask>", + handle_command_whois, HANDLER_SERVER }, + { "whowas", "Get user information", + "<user> [<count> [<target>]]", + handle_command_whowas, HANDLER_SERVER }, + + { "motd", "Get the Message of The Day", + "[<target>]", + handle_command_motd, HANDLER_SERVER }, + { "oper", "Authenticate as an IRC operator", + "<name> <password>", + handle_command_oper, HANDLER_SERVER }, + { "kill", "Kick another user from the server", + "<user> <comment>", + handle_command_kill, HANDLER_SERVER }, + { "stats", "Query server statistics", + "[<query> [<target>]]", + handle_command_stats, HANDLER_SERVER }, + { "away", "Set away status", + "[<text>]", + handle_command_away, HANDLER_SERVER }, + { "nick", "Change current nick", + "<nickname>", + handle_command_nick, HANDLER_SERVER }, + { "quote", "Send a raw command to the server", + "<command>", + handle_command_quote, HANDLER_SERVER }, +}; + +static bool +try_handle_command_help_option (struct app_context *ctx, const char *name) +{ + struct config_item *item = + config_item_get (ctx->config.root, name, NULL); + if (!item) + return false; + + struct config_schema *schema = item->schema; + if (!schema) + { + log_global_error (ctx, "#s: #s", "Option not recognized", name); + return true; + } + + log_global_indent (ctx, ""); + log_global_indent (ctx, "Option \"#s\":", name); + log_global_indent (ctx, " Description: #s", + schema->comment ? schema->comment : "(none)"); + log_global_indent (ctx, " Type: #s", config_item_type_name (schema->type)); + log_global_indent (ctx, " Default: #s", + schema->default_ ? schema->default_ : "null"); + + struct str tmp = str_make (); + config_item_write (item, false, &tmp); + log_global_indent (ctx, " Current value: #s", tmp.str); + str_free (&tmp); + return true; +} + +static bool +show_command_list (struct app_context *ctx) +{ + log_global_indent (ctx, ""); + log_global_indent (ctx, "Commands:"); + + int longest = 0; + for (size_t i = 0; i < N_ELEMENTS (g_command_handlers); i++) + { + int len = strlen (g_command_handlers[i].name); + longest = MAX (longest, len); + } + for (size_t i = 0; i < N_ELEMENTS (g_command_handlers); i++) + { + struct command_handler *handler = &g_command_handlers[i]; + log_global_indent (ctx, " #&s", xstrdup_printf + ("%-*s %s", longest, handler->name, handler->description)); + } + return true; +} + +static bool +show_command_help (struct app_context *ctx, struct command_handler *handler) +{ + log_global_indent (ctx, ""); + log_global_indent (ctx, "/#s: #s", handler->name, handler->description); + log_global_indent (ctx, " Arguments: #s", + handler->usage ? handler->usage : "(none)"); + return true; +} + +static bool +handle_command_help (struct handler_args *a) +{ + struct app_context *ctx = a->ctx; + if (!*a->arguments) + return show_command_list (ctx); + + const char *word = cut_word (&a->arguments); + + const char *command = word + (*word == '/'); + for (size_t i = 0; i < N_ELEMENTS (g_command_handlers); i++) + { + struct command_handler *handler = &g_command_handlers[i]; + if (!strcasecmp_ascii (command, handler->name)) + return show_command_help (ctx, handler); + } + + if (try_handle_command_help_option (ctx, word)) + return true; + + if (str_map_find (get_aliases_config (ctx), command)) + log_global_status (ctx, "/#s is an alias", command); + else + log_global_error (ctx, "#s: #s", "No such command or option", word); + return true; +} + +static void +init_user_command_map (struct str_map *map) +{ + *map = str_map_make (NULL); + map->key_xfrm = tolower_ascii_strxfrm; + + for (size_t i = 0; i < N_ELEMENTS (g_command_handlers); i++) + { + struct command_handler *handler = &g_command_handlers[i]; + str_map_set (map, handler->name, handler); + } +} + +static bool +process_user_command (struct app_context *ctx, struct buffer *buffer, + const char *command_name, char *input) +{ + static bool initialized = false; + static struct str_map map; + if (!initialized) + { + init_user_command_map (&map); + initialized = true; + } + + if (try_handle_buffer_goto (ctx, command_name)) + return true; + + struct handler_args args = + { + .ctx = ctx, + .buffer = buffer, + .arguments = input, + }; + + struct command_handler *handler; + if (!(handler = str_map_find (&map, command_name))) + return false; + hard_assert (handler->flags == 0 || (handler->flags & HANDLER_SERVER)); + + if ((handler->flags & HANDLER_SERVER) + && args.buffer->type == BUFFER_GLOBAL) + log_global_error (ctx, "/#s: #s", + command_name, "can't do this from a global buffer"); + else if ((handler->flags & HANDLER_SERVER) + && !irc_is_connected ((args.s = args.buffer->server))) + log_server_error (args.s, args.s->buffer, "Not connected"); + else if ((handler->flags & HANDLER_NEEDS_REG) + && args.s->state != IRC_REGISTERED) + log_server_error (args.s, args.s->buffer, "Not registered"); + else if (((handler->flags & HANDLER_CHANNEL_FIRST) + && !(args.channel_name = + try_get_channel (&args, maybe_cut_word))) + || ((handler->flags & HANDLER_CHANNEL_LAST) + && !(args.channel_name = + try_get_channel (&args, maybe_cut_word_from_end)))) + log_server_error (args.s, args.buffer, "/#s: #s", command_name, + "no channel name given and this buffer is not a channel"); + else if (!handler->handler (&args)) + log_global_error (ctx, + "#s: /#s #s", "Usage", handler->name, handler->usage); + return true; +} + +static const char * +expand_alias_escape (const char *p, const char *arguments, struct str *output) +{ + struct strv words = strv_make (); + cstr_split (arguments, " ", true, &words); + + // TODO: eventually also add support for argument ranges + // - Can use ${0}, ${0:}, ${:0}, ${1:-1} with strtol, dispose of $1 syntax + // (default aliases don't use numeric arguments). + // - Start numbering from zero, since we'd have to figure out what to do + // in case we encounter a zero if we keep the current approach. + // - Ignore the sequence altogether if no closing '}' can be found, + // or if the internal format doesn't fit the above syntax. + if (*p >= '1' && *p <= '9') + { + size_t offset = *p - '1'; + if (offset < words.len) + str_append (output, words.vector[offset]); + } + else if (*p == '*') + str_append (output, arguments); + else if (strchr ("$;", *p)) + str_append_c (output, *p); + else + str_append_printf (output, "$%c", *p); + + strv_free (&words); + return ++p; +} + +static void +expand_alias_definition (const char *definition, const char *arguments, + struct strv *commands) +{ + struct str expanded = str_make (); + bool escape = false; + for (const char *p = definition; *p; p++) + { + if (escape) + { + p = expand_alias_escape (p, arguments, &expanded) - 1; + escape = false; + } + else if (*p == ';') + { + strv_append_owned (commands, str_steal (&expanded)); + expanded = str_make (); + } + else if (*p == '$' && p[1]) + escape = true; + else + str_append_c (&expanded, *p); + } + strv_append_owned (commands, str_steal (&expanded)); +} + +static bool +expand_alias (struct app_context *ctx, + const char *alias_name, char *input, struct strv *commands) +{ + struct config_item *entry = + str_map_find (get_aliases_config (ctx), alias_name); + if (!entry) + return false; + + if (!config_item_type_is_string (entry->type)) + { + log_global_error (ctx, "Error executing `/#s': #s", + alias_name, "alias definition is not a string"); + return false; + } + + expand_alias_definition (entry->value.string.str, input, commands); + return true; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +send_message_to_target (struct server *s, + const char *target, char *message, struct buffer *buffer) +{ + if (!irc_is_connected (s)) + log_server_error (s, buffer, "Not connected"); + else + SEND_AUTOSPLIT_PRIVMSG (s, target, message); +} + +static void +send_message_to_buffer (struct app_context *ctx, struct buffer *buffer, + char *message) +{ + hard_assert (buffer != NULL); + + switch (buffer->type) + { + case BUFFER_CHANNEL: + send_message_to_target (buffer->server, + buffer->channel->name, message, buffer); + break; + case BUFFER_PM: + send_message_to_target (buffer->server, + buffer->user->nickname, message, buffer); + break; + default: + log_full (ctx, NULL, buffer, 0, BUFFER_LINE_ERROR, + "This buffer is not a channel"); + } +} + +static bool +process_alias (struct app_context *ctx, struct buffer *buffer, + struct strv *commands, int level) +{ + for (size_t i = 0; i < commands->len; i++) + log_global_debug (ctx, "Alias expanded to: ###d: \"#s\"", + (int) i, commands->vector[i]); + for (size_t i = 0; i < commands->len; i++) + if (!process_input_line (ctx, buffer, commands->vector[i], ++level)) + return false; + return true; +} + +static bool +process_input_line_posthook (struct app_context *ctx, struct buffer *buffer, + char *input, int alias_level) +{ + if (*input != '/' || *++input == '/') + { + send_message_to_buffer (ctx, buffer, input); + return true; + } + + char *name = cut_word (&input); + if (process_user_command (ctx, buffer, name, input)) + return true; + + struct strv commands = strv_make (); + bool result = false; + if (!expand_alias (ctx, name, input, &commands)) + log_global_error (ctx, "#s: /#s", "No such command or alias", name); + else if (alias_level != 0) + log_global_error (ctx, "#s: /#s", "Aliases can't nest", name); + else + result = process_alias (ctx, buffer, &commands, alias_level); + + strv_free (&commands); + return result; +} + +static char * +process_input_hooks (struct app_context *ctx, struct buffer *buffer, + char *input) +{ + uint64_t hash = siphash_wrapper (input, strlen (input)); + LIST_FOR_EACH (struct hook, iter, ctx->input_hooks) + { + struct input_hook *hook = (struct input_hook *) iter; + if (!(input = hook->filter (hook, buffer, input))) + { + log_global_debug (ctx, "Input thrown away by hook"); + return NULL; + } + + uint64_t new_hash = siphash_wrapper (input, strlen (input)); + if (new_hash != hash) + log_global_debug (ctx, "Input transformed to \"#s\"#r", input); + hash = new_hash; + } + return input; +} + +static bool +process_input_line (struct app_context *ctx, struct buffer *buffer, + const char *input, int alias_level) +{ + // Note that this also gets called on expanded aliases, + // which might or might not be desirable (we can forward "alias_level") + char *processed = process_input_hooks (ctx, buffer, xstrdup (input)); + bool result = !processed + || process_input_line_posthook (ctx, buffer, processed, alias_level); + free (processed); + return result; +} + +static void +process_input (struct app_context *ctx, struct buffer *buffer, + const char *input) +{ + struct strv lines = strv_make (); + cstr_split (input, "\r\n", false, &lines); + for (size_t i = 0; i < lines.len; i++) + (void) process_input_line (ctx, buffer, lines.vector[i], 0); + strv_free (&lines); +} + +// --- Word completion --------------------------------------------------------- + +// The amount of crap that goes into this is truly insane. +// It's mostly because of Editline's total ignorance of this task. + +static void +completion_free (struct completion *self) +{ + free (self->line); + free (self->words); +} + +static void +completion_add_word (struct completion *self, size_t start, size_t end) +{ + if (!self->words) + self->words = xcalloc ((self->words_alloc = 4), sizeof *self->words); + if (self->words_len == self->words_alloc) + self->words = xreallocarray (self->words, + (self->words_alloc <<= 1), sizeof *self->words); + self->words[self->words_len++] = (struct completion_word) { start, end }; +} + +static struct completion +completion_make (const char *line, size_t len) +{ + struct completion self = { .line = xstrndup (line, len) }; + + // The first and the last word may be empty + const char *s = self.line; + while (true) + { + const char *start = s; + size_t word_len = strcspn (s, WORD_BREAKING_CHARS); + const char *end = start + word_len; + s = end + strspn (end, WORD_BREAKING_CHARS); + + completion_add_word (&self, start - self.line, end - self.line); + if (s == end) + break; + } + return self; +} + +static void +completion_locate (struct completion *self, size_t offset) +{ + size_t i = 0; + for (; i < self->words_len; i++) + if (self->words[i].start > offset) + break; + self->location = i - 1; +} + +static char * +completion_word (struct completion *self, int word) +{ + hard_assert (word >= 0 && word < (int) self->words_len); + return xstrndup (self->line + self->words[word].start, + self->words[word].end - self->words[word].start); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// XXX: this isn't completely right because Unicode, but let's keep it simple. +// At worst it will stop before a combining mark, or fail to compare +// non-ASCII identifiers case-insensitively. + +static size_t +utf8_common_prefix (const char **vector, size_t len) +{ + size_t prefix = 0; + if (!vector || !len) + return 0; + + struct utf8_iter a[len]; + for (size_t i = 0; i < len; i++) + a[i] = utf8_iter_make (vector[i]); + + size_t ch_len; + int32_t ch; + while ((ch = utf8_iter_next (&a[0], &ch_len)) >= 0) + { + for (size_t i = 1; i < len; i++) + { + int32_t other = utf8_iter_next (&a[i], NULL); + if (ch == other) + continue; + // Not bothering with lowercasing non-ASCII + if (ch >= 0x80 || other >= 0x80 + || tolower_ascii (ch) != tolower_ascii (other)) + return prefix; + } + prefix += ch_len; + } + return prefix; +} + +static void +complete_command (struct app_context *ctx, struct completion *data, + const char *word, struct strv *output) +{ + (void) data; + + const char *prefix = ""; + if (*word == '/') + { + word++; + prefix = "/"; + } + + size_t word_len = strlen (word); + for (size_t i = 0; i < N_ELEMENTS (g_command_handlers); i++) + { + struct command_handler *handler = &g_command_handlers[i]; + if (!strncasecmp_ascii (word, handler->name, word_len)) + strv_append_owned (output, + xstrdup_printf ("%s%s", prefix, handler->name)); + } + + struct str_map_iter iter = str_map_iter_make (get_aliases_config (ctx)); + struct config_item *alias; + while ((alias = str_map_iter_next (&iter))) + { + if (!strncasecmp_ascii (word, iter.link->key, word_len)) + strv_append_owned (output, + xstrdup_printf ("%s%s", prefix, iter.link->key)); + } +} + +static void +complete_option (struct app_context *ctx, struct completion *data, + const char *word, struct strv *output) +{ + (void) data; + + struct strv options = strv_make (); + config_dump (ctx->config.root, &options); + strv_sort (&options); + + // Wildcard expansion is an interesting side-effect + char *mask = xstrdup_printf ("%s*", word); + for (size_t i = 0; i < options.len; i++) + { + char *key = cstr_cut_until (options.vector[i], " "); + if (!fnmatch (mask, key, 0)) + strv_append_owned (output, key); + else + free (key); + } + free (mask); + strv_free (&options); +} + +static void +complete_set_value (struct config_item *item, const char *word, + struct strv *output) +{ + struct str serialized = str_make (); + config_item_write (item, false, &serialized); + if (!strncmp (serialized.str, word, strlen (word))) + strv_append_owned (output, str_steal (&serialized)); + else + str_free (&serialized); +} + +static void +complete_set_value_array (struct config_item *item, const char *word, + struct strv *output) +{ + if (!item->schema || item->schema->type != CONFIG_ITEM_STRING_ARRAY) + return; + + struct strv items = strv_make (); + cstr_split (item->value.string.str, ",", false, &items); + for (size_t i = 0; i < items.len; i++) + { + struct str wrapped = str_from_cstr (items.vector[i]); + struct str serialized = str_make (); + config_item_write_string (&serialized, &wrapped); + str_free (&wrapped); + + if (!strncmp (serialized.str, word, strlen (word))) + strv_append_owned (output, str_steal (&serialized)); + else + str_free (&serialized); + } + strv_free (&items); +} + +static void +complete_set (struct app_context *ctx, struct completion *data, + const char *word, struct strv *output) +{ + if (data->location == 1) + { + complete_option (ctx, data, word, output); + return; + } + if (data->location != 3) + return; + + char *key = completion_word (data, 1); + struct config_item *item = config_item_get (ctx->config.root, key, NULL); + if (item) + { + char *op = completion_word (data, 2); + if (!strcmp (op, "-=")) complete_set_value_array (item, word, output); + if (!strcmp (op, "=")) complete_set_value (item, word, output); + free (op); + } + free (key); +} + +static void +complete_topic (struct buffer *buffer, struct completion *data, + const char *word, struct strv *output) +{ + (void) data; + + // TODO: make it work in other server-related buffers, too, i.e. when we're + // completing the third word and the second word is a known channel name + if (buffer->type != BUFFER_CHANNEL) + return; + + const char *topic = buffer->channel->topic; + if (topic && !strncasecmp_ascii (word, topic, strlen (word))) + { + // We must prepend the channel name if the topic itself starts + // with something that could be regarded as a channel name + strv_append_owned (output, irc_is_channel (buffer->server, topic) + ? xstrdup_printf ("%s %s", buffer->channel->name, topic) + : xstrdup (topic)); + } +} + +static void +complete_nicknames (struct buffer *buffer, struct completion *data, + const char *word, struct strv *output) +{ + size_t word_len = strlen (word); + if (buffer->type == BUFFER_SERVER) + { + struct user *self_user = buffer->server->irc_user; + if (self_user && !irc_server_strncmp (buffer->server, + word, self_user->nickname, word_len)) + strv_append (output, self_user->nickname); + } + if (buffer->type != BUFFER_CHANNEL) + return; + + LIST_FOR_EACH (struct channel_user, iter, buffer->channel->users) + { + const char *nickname = iter->user->nickname; + if (irc_server_strncmp (buffer->server, word, nickname, word_len)) + continue; + strv_append_owned (output, data->location == 0 + ? xstrdup_printf ("%s:", nickname) + : xstrdup (nickname)); + } +} + +static struct strv +complete_word (struct app_context *ctx, struct buffer *buffer, + struct completion *data, const char *word) +{ + char *initial = completion_word (data, 0); + + // Start with a placeholder for the longest common prefix + struct strv words = strv_make (); + strv_append_owned (&words, NULL); + + if (data->location == 0 && *initial == '/') + complete_command (ctx, data, word, &words); + else if (data->location >= 1 && !strcmp (initial, "/set")) + complete_set (ctx, data, word, &words); + else if (data->location == 1 && !strcmp (initial, "/help")) + { + complete_command (ctx, data, word, &words); + complete_option (ctx, data, word, &words); + } + else if (data->location == 1 && !strcmp (initial, "/topic")) + { + complete_topic (buffer, data, word, &words); + complete_nicknames (buffer, data, word, &words); + } + else + complete_nicknames (buffer, data, word, &words); + + cstr_set (&initial, NULL); + LIST_FOR_EACH (struct hook, iter, ctx->completion_hooks) + { + struct completion_hook *hook = (struct completion_hook *) iter; + hook->complete (hook, data, word, &words); + } + + if (words.len <= 2) + { + // When nothing matches, this copies the sentinel value + words.vector[0] = words.vector[1]; + words.vector[1] = NULL; + words.len--; + } + else + { + size_t prefix = utf8_common_prefix + ((const char **) words.vector + 1, words.len - 1); + if (!prefix) + words.vector[0] = xstrdup (word); + else + words.vector[0] = xstrndup (words.vector[1], prefix); + } + return words; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +/// A special wrapper for iconv_xstrdup() that also fixes indexes into the +/// original string to point to the right location in the output. +/// Thanks, Readline! Without you I would have never needed to deal with this. +static char * +locale_to_utf8 (struct app_context *ctx, const char *locale, + int *indexes[], size_t n_indexes) +{ + mbstate_t state; + memset (&state, 0, sizeof state); + + size_t remaining = strlen (locale) + 1; + const char *p = locale; + + // Reset the shift state, FWIW + // TODO: Don't use this iconv handle directly at all, elsewhere in xC. + // And ideally use U+FFFD with EILSEQ. + (void) iconv (ctx->term_to_utf8, NULL, NULL, NULL, NULL); + + bool fixed[n_indexes]; + memset (fixed, 0, sizeof fixed); + + struct str utf8 = str_make (); + while (true) + { + size_t len = mbrlen (p, remaining, &state); + + // Incomplete multibyte character or illegal sequence (probably) + if (len == (size_t) -2 + || len == (size_t) -1) + { + str_free (&utf8); + return NULL; + } + + // Convert indexes into the multibyte string to UTF-8 + for (size_t i = 0; i < n_indexes; i++) + if (!fixed[i] && *indexes[i] <= p - locale) + { + *indexes[i] = utf8.len; + fixed[i] = true; + } + + // End of string + if (!len) + break; + + // EINVAL (incomplete sequence) should never happen and + // EILSEQ neither because we've already checked for that with mbrlen(). + // E2BIG is what iconv_xstrdup solves. This must succeed. + size_t ch_len; + char *ch = iconv_xstrdup (ctx->term_to_utf8, (char *) p, len, &ch_len); + hard_assert (ch != NULL); + str_append_data (&utf8, ch, ch_len); + free (ch); + + p += len; + remaining -= len; + } + return str_steal (&utf8); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static struct strv +make_completions (struct app_context *ctx, struct buffer *buffer, + const char *line_utf8, size_t start, size_t end) +{ + struct completion comp = completion_make (line_utf8, strlen (line_utf8)); + completion_locate (&comp, start); + char *word = xstrndup (line_utf8 + start, end - start); + struct strv completions = complete_word (ctx, buffer, &comp, word); + free (word); + completion_free (&comp); + return completions; +} + +/// Takes a line in locale-specific encoding and position of a word to complete, +/// returns a vector of matches in locale-specific encoding. +static char ** +make_input_completions + (struct app_context *ctx, const char *line, int start, int end) +{ + int *fixes[] = { &start, &end }; + char *line_utf8 = locale_to_utf8 (ctx, line, fixes, N_ELEMENTS (fixes)); + if (!line_utf8) + return NULL; + + hard_assert (start >= 0 && end >= 0 && start <= end); + + struct strv completions = + make_completions (ctx, ctx->current_buffer, line_utf8, start, end); + free (line_utf8); + if (!completions.len) + { + strv_free (&completions); + return NULL; + } + for (size_t i = 0; i < completions.len; i++) + { + char *converted = iconv_xstrdup + (ctx->term_from_utf8, completions.vector[i], -1, NULL); + if (!soft_assert (converted)) + converted = xstrdup ("?"); + cstr_set (&completions.vector[i], converted); + } + return completions.vector; +} + +// --- Common code for user actions -------------------------------------------- + +static void +toggle_bracketed_paste (bool enable) +{ + fprintf (stdout, "\x1b[?2004%c", "lh"[enable]); + fflush (stdout); +} + +static void +suspend_terminal (struct app_context *ctx) +{ + // Terminal can get suspended by both the pager and SIGTSTP handling + if (ctx->terminal_suspended++ > 0) + return; + + toggle_bracketed_paste (false); + CALL (ctx->input, hide); + poller_fd_reset (&ctx->tty_event); + + CALL_ (ctx->input, prepare, false); +} + +static void +resume_terminal (struct app_context *ctx) +{ + if (--ctx->terminal_suspended > 0) + return; + + update_screen_size (); + CALL_ (ctx->input, prepare, true); + CALL (ctx->input, on_tty_resized); + + toggle_bracketed_paste (true); + // In theory we could just print all unseen messages but this is safer + buffer_print_backlog (ctx, ctx->current_buffer); + // Now it's safe to process any user input + poller_fd_set (&ctx->tty_event, POLLIN); + CALL (ctx->input, show); +} + +static pid_t +spawn_helper_child (struct app_context *ctx) +{ + suspend_terminal (ctx); + pid_t child = fork (); + switch (child) + { + case -1: + { + int saved_errno = errno; + resume_terminal (ctx); + errno = saved_errno; + break; + } + case 0: + // Put the child in a new foreground process group + hard_assert (setpgid (0, 0) != -1); + hard_assert (tcsetpgrp (STDOUT_FILENO, getpgid (0)) != -1); + break; + default: + // Make sure of it in the parent as well before continuing + (void) setpgid (child, child); + } + return child; +} + +static void +redraw_screen (struct app_context *ctx) +{ + // If by some circumstance we had the wrong idea + CALL (ctx->input, on_tty_resized); + update_screen_size (); + + CALL (ctx->input, hide); + buffer_print_backlog (ctx, ctx->current_buffer); + CALL (ctx->input, show); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +dump_input_to_file (struct app_context *ctx, char *template, struct error **e) +{ + mode_t mask = umask (S_IXUSR | S_IRWXG | S_IRWXO); + int fd = mkstemp (template); + (void) umask (mask); + + if (fd < 0) + return error_set (e, "%s", strerror (errno)); + + char *input = CALL_ (ctx->input, get_line, NULL); + bool success = xwrite (fd, input, strlen (input), e); + free (input); + + if (!success) + (void) unlink (template); + + xclose (fd); + return success; +} + +static char * +try_dump_input_to_file (struct app_context *ctx) +{ + char *template = resolve_filename + ("input.XXXXXX", resolve_relative_runtime_template); + + struct error *e = NULL; + if (dump_input_to_file (ctx, template, &e)) + return template; + + log_global_error (ctx, "#s: #s", + "Failed to create a temporary file for editing", e->message); + error_free (e); + free (template); + return NULL; +} + +static struct strv +build_editor_command (struct app_context *ctx, const char *filename) +{ + struct strv argv = strv_make (); + const char *editor = get_config_string (ctx->config.root, "general.editor"); + if (!editor) + { + const char *command; + if (!(command = getenv ("VISUAL")) + && !(command = getenv ("EDITOR"))) + command = "vi"; + + // Although most visual editors support a "+LINE" argument + // (every editor mentioned in the default value of general.editor, + // plus vi, mcedit, vis, ...), it isn't particularly useful by itself. + // We need to be able to specify the column number. + // + // Seeing as less popular software may try to process this as a filename + // and fail, do not bother with this "undocumented standard feature". + strv_append (&argv, command); + strv_append (&argv, filename); + return argv; + } + + int cursor = 0; + char *input = CALL_ (ctx->input, get_line, &cursor); + hard_assert (cursor >= 0); + + mbstate_t ps; + memset (&ps, 0, sizeof ps); + + wchar_t wch; + size_t len, processed = 0, line_one_based = 1, column = 0; + while (processed < (size_t) cursor + && (len = mbrtowc (&wch, input + processed, cursor - processed, &ps)) + && len != (size_t) -2 && len != (size_t) -1) + { + // Both VIM and Emacs use the caret notation with columns. + // Consciously leaving tabs broken, they're too difficult to handle. + int width = wcwidth (wch); + if (width < 0) + width = 2; + + processed += len; + if (wch == '\n') + { + line_one_based++; + column = 0; + } + else + column += width; + } + free (input); + + // Trivially split the command on spaces and substitute our values + struct str argument = str_make (); + for (; *editor; editor++) + { + if (*editor == ' ') + { + if (argument.len) + { + strv_append_owned (&argv, str_steal (&argument)); + argument = str_make (); + } + continue; + } + if (*editor != '%' || !editor[1]) + { + str_append_c (&argument, *editor); + continue; + } + + // None of them are zero-length, thus words don't get lost + switch (*++editor) + { + case 'F': + str_append (&argument, filename); + continue; + case 'L': + str_append_printf (&argument, "%zu", line_one_based); + continue; + case 'C': + str_append_printf (&argument, "%zu", column + 1); + continue; + case 'B': + str_append_printf (&argument, "%d", cursor + 1); + continue; + case '%': + case ' ': + str_append_c (&argument, *editor); + continue; + } + + const char *p = editor; + if (soft_assert (utf8_decode (&p, strlen (p)) > 0)) + { + log_global_error (ctx, "Unknown substitution variable: %#&s", + xstrndup (editor, p - editor)); + } + } + if (argument.len) + strv_append_owned (&argv, str_steal (&argument)); + else + str_free (&argument); + return argv; +} + +static bool +on_edit_input (int count, int key, void *user_data) +{ + (void) count; + (void) key; + struct app_context *ctx = user_data; + + char *filename; + if (!(filename = try_dump_input_to_file (ctx))) + return false; + + struct strv argv = build_editor_command (ctx, filename); + if (!argv.len) + strv_append (&argv, "true"); + + hard_assert (!ctx->running_editor); + switch (spawn_helper_child (ctx)) + { + case 0: + execvp (argv.vector[0], argv.vector); + print_error ("%s: %s", + "Failed to launch editor", strerror (errno)); + _exit (EXIT_FAILURE); + case -1: + log_global_error (ctx, "#s: #l", + "Failed to launch editor", strerror (errno)); + free (filename); + break; + default: + ctx->running_editor = true; + ctx->editor_filename = filename; + } + strv_free (&argv); + return true; +} + +static void +input_editor_process (struct app_context *ctx) +{ + struct str input = str_make (); + struct error *e = NULL; + if (!read_file (ctx->editor_filename, &input, &e)) + { + log_global_error (ctx, "#s: #s", "Input editing failed", e->message); + error_free (e); + } + else + CALL (ctx->input, clear_line); + + if (!CALL_ (ctx->input, insert, input.str)) + log_global_error (ctx, "#s: #s", "Input editing failed", + "could not re-insert the modified text"); + + str_free (&input); +} + +static void +input_editor_cleanup (struct app_context *ctx) +{ + if (unlink (ctx->editor_filename)) + log_global_error (ctx, "Could not unlink `#l': #l", + ctx->editor_filename, strerror (errno)); + + cstr_set (&ctx->editor_filename, NULL); + ctx->running_editor = false; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +launch_pager (struct app_context *ctx, + int fd, const char *name, const char *path) +{ + hard_assert (!ctx->running_pager); + switch (spawn_helper_child (ctx)) + { + case 0: + dup2 (fd, STDIN_FILENO); + char *localized_name = + iconv_xstrdup (ctx->term_from_utf8, (char *) name, -1, NULL); + execl ("/bin/sh", "/bin/sh", "-c", + get_config_string (ctx->config.root, "general.pager"), + PROGRAM_NAME, localized_name, path, NULL); + print_error ("%s: %s", "Failed to launch pager", strerror (errno)); + _exit (EXIT_FAILURE); + case -1: + log_global_error (ctx, "#s: #l", + "Failed to launch pager", strerror (errno)); + break; + default: + ctx->running_pager = true; + } +} + +static bool +display_backlog (struct app_context *ctx, int flush_opts) +{ + FILE *backlog = tmpfile (); + if (!backlog) + { + log_global_error (ctx, "#s: #l", + "Failed to create a temporary file", strerror (errno)); + return false; + } + + if (!get_config_boolean (ctx->config.root, + "general.pager_strip_formatting")) + flush_opts |= FLUSH_OPT_RAW; + + struct buffer *buffer = ctx->current_buffer; + int until_marker = + (int) buffer->lines_count - (int) buffer->new_messages_count; + for (struct buffer_line *line = buffer->lines; line; line = line->next) + { + if (until_marker-- == 0 + && buffer->new_messages_count != buffer->lines_count) + buffer_print_read_marker (ctx, backlog, flush_opts); + if (buffer_line_will_show_up (buffer, line)) + buffer_line_write_to_backlog (ctx, line, backlog, flush_opts); + } + + // So that it is obvious if the last line in the buffer is not from today + buffer_update_time (ctx, time (NULL), backlog, flush_opts); + + rewind (backlog); + set_cloexec (fileno (backlog)); + launch_pager (ctx, fileno (backlog), buffer->name, NULL); + fclose (backlog); + return true; +} + +static bool +on_display_backlog (int count, int key, void *user_data) +{ + (void) count; + (void) key; + return display_backlog (user_data, 0); +} + +static bool +on_display_backlog_nowrap (int count, int key, void *user_data) +{ + (void) count; + (void) key; + return display_backlog (user_data, FLUSH_OPT_NOWRAP); +} + +static FILE * +open_log_path (struct app_context *ctx, struct buffer *buffer, const char *path) +{ + FILE *fp = fopen (path, "rb"); + if (!fp) + { + log_global_error (ctx, + "Failed to open `#l': #l", path, strerror (errno)); + return NULL; + } + + if (buffer->log_file) + // The regular flush will log any error eventually + (void) fflush (buffer->log_file); + + set_cloexec (fileno (fp)); + return fp; +} + +static bool +on_display_full_log (int count, int key, void *user_data) +{ + (void) count; + (void) key; + struct app_context *ctx = user_data; + + struct buffer *buffer = ctx->current_buffer; + char *path = buffer_get_log_path (buffer); + FILE *full_log = open_log_path (ctx, buffer, path); + if (!full_log) + { + free (path); + return false; + } + + launch_pager (ctx, fileno (full_log), buffer->name, path); + fclose (full_log); + free (path); + return true; +} + +static bool +on_toggle_unimportant (int count, int key, void *user_data) +{ + (void) count; + (void) key; + struct app_context *ctx = user_data; + buffer_toggle_unimportant (ctx, ctx->current_buffer); + return true; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +on_goto_buffer (int count, int key, void *user_data) +{ + (void) count; + struct app_context *ctx = user_data; + + int n = key - '0'; + if (n < 0 || n > 9) + return false; + + // There's no buffer zero + if (n == 0) + n = 10; + + if (!ctx->last_buffer || buffer_get_index (ctx, ctx->current_buffer) != n) + return buffer_goto (ctx, n); + + // Fast switching between two buffers + buffer_activate (ctx, ctx->last_buffer); + return true; +} + +static bool +on_previous_buffer (int count, int key, void *user_data) +{ + (void) key; + buffer_activate (user_data, buffer_previous (user_data, count)); + return true; +} + +static bool +on_next_buffer (int count, int key, void *user_data) +{ + (void) key; + buffer_activate (user_data, buffer_next (user_data, count)); + return true; +} + +static bool +on_switch_buffer (int count, int key, void *user_data) +{ + (void) count; + (void) key; + struct app_context *ctx = user_data; + + if (!ctx->last_buffer) + return false; + buffer_activate (ctx, ctx->last_buffer); + return true; +} + +static bool +on_goto_highlight (int count, int key, void *user_data) +{ + (void) count; + (void) key; + + struct app_context *ctx = user_data; + struct buffer *iter = ctx->current_buffer;; + do + { + if (!(iter = iter->next)) + iter = ctx->buffers; + if (iter == ctx->current_buffer) + return false; + } + while (!iter->highlighted); + buffer_activate (ctx, iter); + return true; +} + +static bool +on_goto_activity (int count, int key, void *user_data) +{ + (void) count; + (void) key; + + struct app_context *ctx = user_data; + struct buffer *iter = ctx->current_buffer; + do + { + if (!(iter = iter->next)) + iter = ctx->buffers; + if (iter == ctx->current_buffer) + return false; + } + while (iter->new_messages_count == iter->new_unimportant_count); + buffer_activate (ctx, iter); + return true; +} + +static bool +on_move_buffer_left (int count, int key, void *user_data) +{ + (void) key; + + struct app_context *ctx = user_data; + int total = buffer_count (ctx); + int n = buffer_get_index (ctx, ctx->current_buffer) - count; + buffer_move (ctx, ctx->current_buffer, n <= 0 + ? (total + n % total) + : ((n - 1) % total + 1)); + return true; +} + +static bool +on_move_buffer_right (int count, int key, void *user_data) +{ + return on_move_buffer_left (-count, key, user_data); +} + +static bool +on_redraw_screen (int count, int key, void *user_data) +{ + (void) count; + (void) key; + + redraw_screen (user_data); + return true; +} + +static bool +on_insert_attribute (int count, int key, void *user_data) +{ + (void) count; + (void) key; + + struct app_context *ctx = user_data; + ctx->awaiting_formatting_escape = true; + return true; +} + +static bool +on_start_paste_mode (int count, int key, void *user_data) +{ + (void) count; + (void) key; + + struct app_context *ctx = user_data; + ctx->in_bracketed_paste = true; + return true; +} + +static void +input_add_functions (void *user_data) +{ + struct app_context *ctx = user_data; +#define XX(...) CALL_ (ctx->input, register_fn, __VA_ARGS__, ctx); + XX ("previous-buffer", "Previous buffer", on_previous_buffer) + XX ("next-buffer", "Next buffer", on_next_buffer) + XX ("goto-buffer", "Go to buffer", on_goto_buffer) + XX ("switch-buffer", "Switch buffer", on_switch_buffer) + XX ("goto-highlight", "Go to highlight", on_goto_highlight) + XX ("goto-activity", "Go to activity", on_goto_activity) + XX ("move-buffer-left", "Move buffer left", on_move_buffer_left) + XX ("move-buffer-right", "Move buffer right", on_move_buffer_right) + XX ("display-backlog", "Show backlog", on_display_backlog) + XX ("display-backlog-nw", "Non-wrapped log", on_display_backlog_nowrap) + XX ("display-full-log", "Show full log", on_display_full_log) + XX ("toggle-unimportant", "Toggle junk msgs", on_toggle_unimportant) + XX ("edit-input", "Edit input", on_edit_input) + XX ("redraw-screen", "Redraw screen", on_redraw_screen) + XX ("insert-attribute", "IRC formatting", on_insert_attribute) + XX ("start-paste-mode", "Bracketed paste", on_start_paste_mode) +#undef XX +} + +static void +bind_common_keys (struct app_context *ctx) +{ + struct input *self = ctx->input; + CALL_ (self, bind_control, 'p', "previous-buffer"); + CALL_ (self, bind_control, 'n', "next-buffer"); + + // Redefine M-0 through M-9 to switch buffers + for (int i = 0; i <= 9; i++) + CALL_ (self, bind_meta, '0' + i, "goto-buffer"); + + CALL_ (self, bind_meta, '\t', "switch-buffer"); + CALL_ (self, bind_meta, '!', "goto-highlight"); + CALL_ (self, bind_meta, 'a', "goto-activity"); + CALL_ (self, bind_meta, 'm', "insert-attribute"); + CALL_ (self, bind_meta, 'h', "display-full-log"); + CALL_ (self, bind_meta, 'H', "toggle-unimportant"); + CALL_ (self, bind_meta, 'e', "edit-input"); + + if (key_f5) CALL_ (self, bind, key_f5, "previous-buffer"); + if (key_f6) CALL_ (self, bind, key_f6, "next-buffer"); + if (key_ppage) CALL_ (self, bind, key_ppage, "display-backlog"); + + if (clear_screen) + CALL_ (self, bind_control, 'l', "redraw-screen"); + + CALL_ (self, bind, "\x1b[200~", "start-paste-mode"); +} + +// --- GNU Readline user actions ----------------------------------------------- + +#ifdef HAVE_READLINE + +static int +on_readline_return (int count, int key) +{ + (void) count; + (void) key; + + // Let readline pass the line to our input handler + rl_done = 1; + + struct app_context *ctx = g_ctx; + struct input_rl *self = (struct input_rl *) ctx->input; + + // Hide the line, don't redisplay it + CALL (ctx->input, hide); + input_rl__restore (self); + return 0; +} + +static void +on_readline_input (char *line) +{ + struct app_context *ctx = g_ctx; + struct input_rl *self = (struct input_rl *) ctx->input; + + if (line) + { + if (*line) + add_history (line); + + // readline always erases the input line after returning from here, + // but we don't want that to happen if the command to be executed + // would switch the buffer (we'd keep the already executed command in + // the old buffer and delete any input restored from the new buffer) + strv_append_owned (&ctx->pending_input, line); + poller_idle_set (&ctx->input_event); + } + else + { + // Prevent readline from showing the prompt twice for w/e reason + CALL (ctx->input, hide); + input_rl__restore (self); + + CALL (ctx->input, ding); + } + + if (self->active) + // Readline automatically redisplays it + self->prompt_shown = 1; +} + +static char ** +app_readline_completion (const char *text, int start, int end) +{ + // We will reconstruct that ourselves + (void) text; + + // Don't iterate over filenames and stuff + rl_attempted_completion_over = true; + + return make_input_completions (g_ctx, rl_line_buffer, start, end); +} + +static void +app_readline_display_matches (char **matches, int len, int longest) +{ + struct app_context *ctx = g_ctx; + CALL (ctx->input, hide); + rl_display_match_list (matches, len, longest); + CALL (ctx->input, show); +} + +static int +app_readline_init (void) +{ + struct app_context *ctx = g_ctx; + struct input *self = ctx->input; + + // XXX: maybe use rl_make_bare_keymap() and start from there; + // our dear user could potentionally rig things up in a way that might + // result in some funny unspecified behaviour + + // For vi mode, enabling "show-mode-in-prompt" is recommended as there is + // no easy way to indicate mode changes otherwise. + + rl_add_defun ("send-line", on_readline_return, -1); + bind_common_keys (ctx); + + // Move native history commands + CALL_ (self, bind_meta, 'p', "previous-history"); + CALL_ (self, bind_meta, 'n', "next-history"); + + // We need to hide the prompt and input first + rl_bind_key (RETURN, rl_named_function ("send-line")); + CALL_ (self, bind_control, 'j', "send-line"); + + rl_completion_display_matches_hook = app_readline_display_matches; + + rl_variable_bind ("completion-ignore-case", "on"); + rl_variable_bind ("menu-complete-display-prefix", "on"); + rl_bind_key (TAB, rl_named_function ("menu-complete")); + if (key_btab) + CALL_ (self, bind, key_btab, "menu-complete-backward"); + return 0; +} + +#endif // HAVE_READLINE + +// --- BSD Editline user actions ----------------------------------------------- + +#ifdef HAVE_EDITLINE + +static unsigned char +on_editline_complete (EditLine *editline, int key) +{ + (void) key; + struct app_context *ctx = g_ctx; + + // First prepare what Readline would have normally done for us... + const LineInfo *info_mb = el_line (editline); + int len = info_mb->lastchar - info_mb->buffer; + int point = info_mb->cursor - info_mb->buffer; + char *copy = xstrndup (info_mb->buffer, len); + + // XXX: possibly incorrect wrt. shift state encodings + int el_start = point, el_end = point; + while (el_start && !strchr (WORD_BREAKING_CHARS, copy[el_start - 1])) + el_start--; + + char **completions = make_input_completions (ctx, copy, el_start, el_end); + + // XXX: possibly incorrect wrt. shift state encodings + copy[el_end] = '\0'; + int el_len = mbstowcs (NULL, copy + el_start, 0); + free (copy); + + if (!completions) + return CC_REFRESH_BEEP; + + // Remove the original word + el_wdeletestr (editline, el_len); + + // Insert the best match instead + el_insertstr (editline, completions[0]); + + // I'm not sure if Readline's menu-complete can at all be implemented + // with Editline--we have no way of detecting what the last executed handler + // was. Employ the formatter's wrapping feature to spew all options. + bool only_match = !completions[1]; + if (!only_match) + { + CALL (ctx->input, hide); + redraw_screen (ctx); + + struct formatter f = formatter_make (ctx, NULL); + for (char **p = completions; *++p; ) + formatter_add (&f, " #l", *p); + formatter_add (&f, "\n"); + formatter_flush (&f, stdout, 0); + formatter_free (&f); + + CALL (ctx->input, show); + } + + for (char **p = completions; *p; p++) + free (*p); + free (completions); + if (!only_match) + return CC_REFRESH_BEEP; + + // If there actually is just one match, finish the word + el_insertstr (editline, " "); + return CC_REFRESH; +} + +static unsigned char +on_editline_return (EditLine *editline, int key) +{ + (void) key; + struct app_context *ctx = g_ctx; + struct input_el *self = (struct input_el *) ctx->input; + + const LineInfoW *info = el_wline (editline); + int len = info->lastchar - info->buffer; + wchar_t *line = calloc (sizeof *info->buffer, len + 1); + memcpy (line, info->buffer, sizeof *info->buffer * len); + + if (*line) + { + HistEventW ev; + history_w (self->current->history, &ev, H_ENTER, line); + } + free (line); + + // on_pending_input() expects a multibyte string + const LineInfo *info_mb = el_line (editline); + strv_append_owned (&ctx->pending_input, + xstrndup (info_mb->buffer, info_mb->lastchar - info_mb->buffer)); + poller_idle_set (&ctx->input_event); + + // We must invoke ch_reset(), which isn't done for us with EL_UNBUFFERED. + input_el__start_over (self); + return CC_REFRESH; +} + +static void +app_editline_init (struct input_el *self) +{ + // el_set() leaks memory in 20150325 and other versions, we need wchar_t + el_wset (self->editline, EL_ADDFN, + L"send-line", L"Send line", on_editline_return); + el_wset (self->editline, EL_ADDFN, + L"complete", L"Complete word", on_editline_complete); + + struct input *input = &self->super; + input->add_functions (input->user_data); + bind_common_keys (g_ctx); + + // Move native history commands + CALL_ (input, bind_meta, 'p', "ed-prev-history"); + CALL_ (input, bind_meta, 'n', "ed-next-history"); + + // No, editline, it's not supposed to kill the entire line + CALL_ (input, bind_control, 'w', "ed-delete-prev-word"); + // Just what are you doing? + CALL_ (input, bind_control, 'u', "vi-kill-line-prev"); + + // We need to hide the prompt and input first + CALL_ (input, bind, "\r", "send-line"); + CALL_ (input, bind, "\n", "send-line"); + + CALL_ (input, bind_control, 'i', "complete"); + + // Source the user's defaults file + el_source (self->editline, NULL); + + // See input_el__redisplay(), functionally important + CALL_ (input, bind_control, 'q', "ed-redisplay"); + // This is what buffered el_wgets() does, functionally important + CALL_ (input, bind_control, 'c', "ed-start-over"); +} + +#endif // HAVE_EDITLINE + +// --- Configuration loading --------------------------------------------------- + +static const char *g_first_time_help[] = +{ + "", + "\x02Welcome to xC!", + "", + "To get a list of all commands, type \x02/help\x02. To obtain", + "more information on a command or option, simply add it as", + "a parameter, e.g. \x02/help set\x02 or \x02/help general.logging\x02.", + "", + "To switch between buffers, press \x02" + "F5/Ctrl-P\x02 or \x02" "F6/Ctrl-N\x02.", + "", + "Finally, adding a network is as simple as:", + " - \x02/server add IRCnet\x02", + " - \x02/set servers.IRCnet.addresses = \"open.ircnet.net\"\x02", + " - \x02/connect IRCnet\x02", + "", + "That should be enough to get you started. Have fun!", + "" +}; + +static void +show_first_time_help (struct app_context *ctx) +{ + for (size_t i = 0; i < N_ELEMENTS (g_first_time_help); i++) + log_global_indent (ctx, "#m", g_first_time_help[i]); +} + +const char *g_default_aliases[][2] = +{ + { "c", "/buffer clear" }, { "close", "/buffer close" }, + { "j", "/join $*" }, { "p", "/part $*" }, + { "k", "/kick $*" }, { "kb", "/kickban $*" }, + { "m", "/msg $*" }, { "q", "/query $*" }, + { "n", "/names $*" }, { "t", "/topic $*" }, + { "w", "/who $*" }, { "wi", "/whois $*" }, + { "ww", "/whowas $*" }, +}; + +static void +load_default_aliases (struct app_context *ctx) +{ + struct str_map *aliases = get_aliases_config (ctx); + for (size_t i = 0; i < N_ELEMENTS (g_default_aliases); i++) + { + const char **pair = g_default_aliases[i]; + str_map_set (aliases, pair[0], config_item_string_from_cstr (pair[1])); + } +} + +static void +load_configuration (struct app_context *ctx) +{ + // In theory, we could ensure that only one instance is running by locking + // the configuration file and ensuring here that it exists. This is + // however brittle, as it may be unlinked without the application noticing. + + struct config_item *root = NULL; + struct error *e = NULL; + + char *filename = resolve_filename + (PROGRAM_NAME ".conf", resolve_relative_config_filename); + if (filename) + root = config_read_from_file (filename, &e); + else + log_global_error (ctx, "Configuration file not found"); + free (filename); + + if (e) + { + log_global_error (ctx, "Cannot load configuration: #s", e->message); + log_global_error (ctx, + "Please either fix the configuration file or remove it"); + error_free (e); + exit (EXIT_FAILURE); + } + + if (root) + { + config_load (&ctx->config, root); + log_global_status (ctx, "Configuration loaded"); + } + else + { + show_first_time_help (ctx); + load_default_aliases (ctx); + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +load_servers (struct app_context *ctx) +{ + struct str_map_iter iter = str_map_iter_make (get_servers_config (ctx)); + + struct config_item *subtree; + while ((subtree = str_map_iter_next (&iter))) + { + const char *name = iter.link->key; + const char *err; + if (subtree->type != CONFIG_ITEM_OBJECT) + log_global_error (ctx, "Error in configuration: " + "ignoring server `#s' as it's not an object", name); + else if ((err = check_server_name_for_addition (ctx, name))) + log_global_error (ctx, "Cannot load server `#s': #s", name, err); + else + server_add (ctx, name, subtree); + } +} + +// --- Signals ----------------------------------------------------------------- + +static int g_signal_pipe[2]; ///< A pipe used to signal... signals + +/// Program termination has been requested by a signal +static volatile sig_atomic_t g_termination_requested; +/// The window has changed in size +static volatile sig_atomic_t g_winch_received; + +static void +postpone_signal_handling (char id) +{ + int original_errno = errno; + if (write (g_signal_pipe[1], &id, 1) == -1) + soft_assert (errno == EAGAIN); + errno = original_errno; +} + +static void +signal_superhandler (int signum) +{ + switch (signum) + { + case SIGWINCH: + g_winch_received = true; + postpone_signal_handling ('w'); + break; + case SIGINT: + case SIGTERM: + g_termination_requested = true; + postpone_signal_handling ('t'); + break; + case SIGCHLD: + postpone_signal_handling ('c'); + break; + case SIGTSTP: + postpone_signal_handling ('s'); + break; + default: + hard_assert (!"unhandled signal"); + } +} + +static void +setup_signal_handlers (void) +{ + if (pipe (g_signal_pipe) == -1) + exit_fatal ("%s: %s", "pipe", strerror (errno)); + + set_cloexec (g_signal_pipe[0]); + set_cloexec (g_signal_pipe[1]); + + // So that the pipe cannot overflow; it would make write() block within + // the signal handler, which is something we really don't want to happen. + // The same holds true for read(). + set_blocking (g_signal_pipe[0], false); + set_blocking (g_signal_pipe[1], false); + + signal (SIGPIPE, SIG_IGN); + + // So that we can write to the terminal while we're running a pager. + // This is also inherited by the child so that it doesn't stop + // when it calls tcsetpgrp(). + signal (SIGTTOU, SIG_IGN); + + struct sigaction sa; + sa.sa_flags = SA_RESTART; + sa.sa_handler = signal_superhandler; + sigemptyset (&sa.sa_mask); + + if (sigaction (SIGWINCH, &sa, NULL) == -1 + || sigaction (SIGINT, &sa, NULL) == -1 + || sigaction (SIGTERM, &sa, NULL) == -1 + || sigaction (SIGTSTP, &sa, NULL) == -1 + || sigaction (SIGCHLD, &sa, NULL) == -1) + exit_fatal ("sigaction: %s", strerror (errno)); +} + +// --- I/O event handlers ------------------------------------------------------ + +static bool +try_reap_child (struct app_context *ctx) +{ + int status; + pid_t zombie = waitpid (-1, &status, WNOHANG | WUNTRACED); + + if (zombie == -1) + { + if (errno == ECHILD) return false; + if (errno == EINTR) return true; + exit_fatal ("%s: %s", "waitpid", strerror (errno)); + } + if (!zombie) + return false; + + if (WIFSTOPPED (status)) + { + // We could also send SIGCONT but what's the point + log_global_debug (ctx, + "A child has been stopped, killing its process group"); + kill (-zombie, SIGKILL); + return true; + } + + if (ctx->running_pager) + ctx->running_pager = false; + else if (!ctx->running_editor) + { + log_global_debug (ctx, "An unknown child has died"); + return true; + } + + hard_assert (tcsetpgrp (STDOUT_FILENO, getpgid (0)) != -1); + resume_terminal (ctx); + + if (WIFSIGNALED (status)) + log_global_error (ctx, + "Child died from signal #d", WTERMSIG (status)); + else if (WIFEXITED (status) && WEXITSTATUS (status) != 0) + log_global_error (ctx, + "Child returned status #d", WEXITSTATUS (status)); + else if (ctx->running_editor) + input_editor_process (ctx); + + if (ctx->running_editor) + input_editor_cleanup (ctx); + return true; +} + +static void +on_signal_pipe_readable (const struct pollfd *fd, struct app_context *ctx) +{ + char id = 0; + (void) read (fd->fd, &id, 1); + + // Stop ourselves cleanly, even if it makes little sense to do this + if (id == 's') + { + suspend_terminal (ctx); + kill (getpid (), SIGSTOP); + g_winch_received = true; + resume_terminal (ctx); + } + + // Reap all dead children (since the signal pipe may overflow etc. we run + // waitpid() in a loop to return all the zombies it knows about). + while (try_reap_child (ctx)) + ; + + if (g_termination_requested) + { + g_termination_requested = false; + request_quit (ctx, NULL); + } + if (g_winch_received) + { + g_winch_received = false; + redraw_screen (ctx); + } +} + +static void +process_formatting_escape (const struct pollfd *fd, struct app_context *ctx) +{ + // There's no other way with libedit, as both el_getc() in a function + // handler and CC_ARGHACK would block execution + struct str *buf = &ctx->input_buffer; + str_reserve (buf, 1); + if (read (fd->fd, buf->str + buf->len, 1) != 1) + goto error; + buf->str[++buf->len] = '\0'; + + // XXX: I think this should be global and shared with Readline/libedit + mbstate_t state; + memset (&state, 0, sizeof state); + + size_t len = mbrlen (buf->str, buf->len, &state); + + // Illegal sequence + if (len == (size_t) -1) + goto error; + + // Incomplete multibyte character + if (len == (size_t) -2) + return; + + if (buf->len != 1) + goto error; + + // Letters mostly taken from their caret escapes + HTML element names. + // Additionally, 'm' stands for mono, 'x' for cross, 'r' for reset. + switch (buf->str[0]) + { + case 'b' ^ 96: + case 'b': CALL_ (ctx->input, insert, "\x02"); break; + case 'c': CALL_ (ctx->input, insert, "\x03"); break; + case 'q': + case 'm': CALL_ (ctx->input, insert, "\x11"); break; + case 'v': CALL_ (ctx->input, insert, "\x16"); break; + case 'i' ^ 96: + case 'i': + case ']': CALL_ (ctx->input, insert, "\x1d"); break; + case 's' ^ 96: + case 's': + case 'x' ^ 96: + case 'x': + case '^': CALL_ (ctx->input, insert, "\x1e"); break; + case 'u' ^ 96: + case 'u': + case '_': CALL_ (ctx->input, insert, "\x1f"); break; + case 'r': + case 'o': CALL_ (ctx->input, insert, "\x0f"); break; + + default: + goto error; + } + goto done; + +error: + CALL (ctx->input, ding); +done: + str_reset (buf); + ctx->awaiting_formatting_escape = false; +} + +#define BRACKETED_PASTE_LIMIT 102400 ///< How much text can be pasted + +static bool +insert_paste (struct app_context *ctx, char *paste, size_t len) +{ + if (!get_config_boolean (ctx->config.root, "general.process_pasted_text")) + return CALL_ (ctx->input, insert, paste); + + // Without ICRNL, which Editline keeps but Readline doesn't, + // the terminal sends newlines as carriage returns (seen on urxvt) + for (size_t i = 0; i < len; i++) + if (paste[i] == '\r') + paste[i] = '\n'; + + int position = 0; + char *input = CALL_ (ctx->input, get_line, &position); + bool quote_first_slash = !position || strchr ("\r\n", input[position - 1]); + free (input); + + // Executing commands by accident is much more common than pasting them + // intentionally, although the latter may also have security consequences + struct str processed = str_make (); + str_reserve (&processed, len); + for (size_t i = 0; i < len; i++) + { + if (paste[i] == '/' + && ((!i && quote_first_slash) || (i && paste[i - 1] == '\n'))) + str_append_c (&processed, paste[i]); + str_append_c (&processed, paste[i]); + } + + bool success = CALL_ (ctx->input, insert, processed.str); + str_free (&processed); + return success; +} + +static void +process_bracketed_paste (const struct pollfd *fd, struct app_context *ctx) +{ + struct str *buf = &ctx->input_buffer; + str_reserve (buf, 1); + if (read (fd->fd, buf->str + buf->len, 1) != 1) + goto error; + buf->str[++buf->len] = '\0'; + + static const char stop_mark[] = "\x1b[201~"; + static const size_t stop_mark_len = sizeof stop_mark - 1; + if (buf->len < stop_mark_len) + return; + + size_t text_len = buf->len - stop_mark_len; + if (memcmp (buf->str + text_len, stop_mark, stop_mark_len)) + return; + + // Avoid endless flooding of the buffer + if (text_len > BRACKETED_PASTE_LIMIT) + log_global_error (ctx, "Paste trimmed to #d bytes", + (int) (text_len = BRACKETED_PASTE_LIMIT)); + + buf->str[text_len] = '\0'; + if (insert_paste (ctx, buf->str, text_len)) + goto done; + +error: + CALL (ctx->input, ding); + log_global_error (ctx, "Paste failed"); +done: + str_reset (buf); + ctx->in_bracketed_paste = false; +} + +static void +reset_autoaway (struct app_context *ctx) +{ + // Stop the last one if it's been disabled altogether in the meantime + poller_timer_reset (&ctx->autoaway_tmr); + + // Unset any automated statuses that are active right at this moment + struct str_map_iter iter = str_map_iter_make (&ctx->servers); + struct server *s; + while ((s = str_map_iter_next (&iter))) + { + if (s->autoaway_active + && s->irc_user + && s->irc_user->away) + irc_send (s, "AWAY"); + + s->autoaway_active = false; + } + + // And potentially start a new auto-away timer + int64_t delay = get_config_integer + (ctx->config.root, "general.autoaway_delay"); + if (delay) + poller_timer_set (&ctx->autoaway_tmr, delay * 1000); +} + +static void +on_autoaway_timer (struct app_context *ctx) +{ + // An empty message would unset any away status, so let's ignore that + const char *message = get_config_string + (ctx->config.root, "general.autoaway_message"); + if (!message || !*message) + return; + + struct str_map_iter iter = str_map_iter_make (&ctx->servers); + struct server *s; + while ((s = str_map_iter_next (&iter))) + { + // If the user has already been marked as away, + // don't override his current away status + if (s->irc_user + && s->irc_user->away) + continue; + + irc_send (s, "AWAY :%s", message); + s->autoaway_active = true; + } +} + +static void +on_tty_readable (const struct pollfd *fd, struct app_context *ctx) +{ + if (fd->revents & ~(POLLIN | POLLHUP | POLLERR)) + print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents); + + if (ctx->awaiting_formatting_escape) + process_formatting_escape (fd, ctx); + else if (ctx->in_bracketed_paste) + process_bracketed_paste (fd, ctx); + else if (!ctx->quitting) + CALL (ctx->input, on_tty_readable); + + // User activity detected, stop current auto-away and start anew; + // since they might have just changed the settings, do this last + reset_autoaway (ctx); +} + +static void +rearm_flush_timer (struct app_context *ctx) +{ + poller_timer_set (&ctx->flush_timer, 60 * 1000); +} + +static void +on_flush_timer (struct app_context *ctx) +{ + // I guess we don't need to do anything more complicated + fflush (NULL); + + // It would be a bit problematic to handle it properly, so do this at least + LIST_FOR_EACH (struct buffer, buffer, ctx->buffers) + { + if (!buffer->log_file || !ferror (buffer->log_file)) + continue; + + // Might be a transient error such as running out of disk space, + // keep notifying of the problem until it disappears + clearerr (buffer->log_file); + log_global (ctx, BUFFER_LINE_SKIP_FILE, BUFFER_LINE_ERROR, + "Log write failure detected for #s", buffer->name); + } + +#ifdef LOMEM + // Lua should normally be reasonable and collect garbage when needed, + // though we can try to push it. This is a reasonable place. + LIST_FOR_EACH (struct plugin, iter, ctx->plugins) + if (iter->vtable->gc) + iter->vtable->gc (iter); +#endif // LOMEM + + rearm_flush_timer (ctx); +} + +static void +rearm_date_change_timer (struct app_context *ctx) +{ + struct tm tm_; + const time_t now = time (NULL); + if (!soft_assert (localtime_r (&now, &tm_))) + return; + + tm_.tm_sec = tm_.tm_min = tm_.tm_hour = 0; + tm_.tm_mday++; + tm_.tm_isdst = -1; + + const time_t midnight = mktime (&tm_); + if (!soft_assert (midnight != (time_t) -1)) + return; + poller_timer_set (&ctx->date_chg_tmr, (midnight - now) * 1000); +} + +static void +on_date_change_timer (struct app_context *ctx) +{ + if (ctx->terminal_suspended <= 0) + { + CALL (ctx->input, hide); + buffer_update_time (ctx, time (NULL), stdout, 0); + CALL (ctx->input, show); + } + rearm_date_change_timer (ctx); +} + +static void +on_pending_input (struct app_context *ctx) +{ + poller_idle_reset (&ctx->input_event); + for (size_t i = 0; i < ctx->pending_input.len; i++) + { + char *input = iconv_xstrdup + (ctx->term_to_utf8, ctx->pending_input.vector[i], -1, NULL); + if (input) + process_input (ctx, ctx->current_buffer, input); + else + print_error ("character conversion failed for: %s", "user input"); + free (input); + } + strv_reset (&ctx->pending_input); +} + +static void +init_poller_events (struct app_context *ctx) +{ + ctx->signal_event = poller_fd_make (&ctx->poller, g_signal_pipe[0]); + ctx->signal_event.dispatcher = (poller_fd_fn) on_signal_pipe_readable; + ctx->signal_event.user_data = ctx; + poller_fd_set (&ctx->signal_event, POLLIN); + + ctx->tty_event = poller_fd_make (&ctx->poller, STDIN_FILENO); + ctx->tty_event.dispatcher = (poller_fd_fn) on_tty_readable; + ctx->tty_event.user_data = ctx; + poller_fd_set (&ctx->tty_event, POLLIN); + + ctx->flush_timer = poller_timer_make (&ctx->poller); + ctx->flush_timer.dispatcher = (poller_timer_fn) on_flush_timer; + ctx->flush_timer.user_data = ctx; + rearm_flush_timer (ctx); + + ctx->date_chg_tmr = poller_timer_make (&ctx->poller); + ctx->date_chg_tmr.dispatcher = (poller_timer_fn) on_date_change_timer; + ctx->date_chg_tmr.user_data = ctx; + rearm_date_change_timer (ctx); + + ctx->autoaway_tmr = poller_timer_make (&ctx->poller); + ctx->autoaway_tmr.dispatcher = (poller_timer_fn) on_autoaway_timer; + ctx->autoaway_tmr.user_data = ctx; + + ctx->prompt_event = poller_idle_make (&ctx->poller); + ctx->prompt_event.dispatcher = (poller_idle_fn) on_refresh_prompt; + ctx->prompt_event.user_data = ctx; + + ctx->input_event = poller_idle_make (&ctx->poller); + ctx->input_event.dispatcher = (poller_idle_fn) on_pending_input; + ctx->input_event.user_data = ctx; +} + +// --- Relay processing -------------------------------------------------------- + +// XXX: This could be below completion code if reset_autoaway() was higher up. + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +client_resync (struct client *c) +{ + struct str_map_iter iter = str_map_iter_make (&c->ctx->servers); + struct server *s; + while ((s = str_map_iter_next (&iter))) + { + relay_prepare_server_update (c->ctx, s); + relay_send (c); + } + + LIST_FOR_EACH (struct buffer, buffer, c->ctx->buffers) + { + relay_prepare_buffer_update (c->ctx, buffer); + relay_send (c); + relay_prepare_buffer_stats (c->ctx, buffer); + relay_send (c); + + LIST_FOR_EACH (struct buffer_line, line, buffer->lines) + { + relay_prepare_buffer_line (c->ctx, buffer, line, false); + relay_send (c); + } + } + + relay_prepare_buffer_activate (c->ctx, c->ctx->current_buffer); + relay_send (c); +} + +static const char * +client_message_buffer_name (const struct relay_command_message *m) +{ + switch (m->data.command) + { + case RELAY_COMMAND_BUFFER_COMPLETE: + return m->data.buffer_input.buffer_name.str; + case RELAY_COMMAND_BUFFER_ACTIVATE: + return m->data.buffer_activate.buffer_name.str; + case RELAY_COMMAND_BUFFER_INPUT: + return m->data.buffer_input.buffer_name.str; + case RELAY_COMMAND_BUFFER_TOGGLE_UNIMPORTANT: + return m->data.buffer_toggle_unimportant.buffer_name.str; + case RELAY_COMMAND_BUFFER_LOG: + return m->data.buffer_log.buffer_name.str; + default: + return NULL; + } +} + +static void +client_process_buffer_complete (struct client *c, uint32_t seq, + struct buffer *buffer, struct relay_command_data_buffer_complete *req) +{ + struct str *line = &req->text; + uint32_t end = req->position; + if (line->len < end || line->len != strlen (line->str)) + { + relay_prepare_error (c->ctx, seq, "Invalid arguments"); + goto out; + } + + uint32_t start = end; + while (start && !strchr (WORD_BREAKING_CHARS, line->str[start - 1])) + start--; + + struct strv completions = + make_completions (c->ctx, buffer, line->str, start, end); + if (completions.len > UINT32_MAX) + { + relay_prepare_error (c->ctx, seq, "Internal error"); + goto out_internal; + } + + struct relay_event_data_response *e = relay_prepare_response (c->ctx, seq); + e->data.command = RELAY_COMMAND_BUFFER_COMPLETE; + struct relay_response_data_buffer_complete *resp = + &e->data.buffer_complete; + resp->start = start; + resp->completions_len = completions.len; + resp->completions = xcalloc (completions.len, sizeof *resp->completions); + for (size_t i = 0; i < completions.len; i++) + resp->completions[i] = str_from_cstr (completions.vector[i]); + +out_internal: + strv_free (&completions); +out: + relay_send (c); +} + +static void +client_process_buffer_log + (struct client *c, uint32_t seq, struct buffer *buffer) +{ + struct relay_event_data_response *e = relay_prepare_response (c->ctx, seq); + e->data.command = RELAY_COMMAND_BUFFER_LOG; + + char *path = buffer_get_log_path (buffer); + FILE *fp = open_log_path (c->ctx, buffer, path); + if (fp) + { + struct str log = str_make (); + char buf[BUFSIZ]; + size_t len; + while ((len = fread (buf, 1, sizeof buf, fp))) + str_append_data (&log, buf, len); + if (ferror (fp)) + log_global_error (c->ctx, "Failed to read `#l': #l", + path, strerror (errno)); + + // On overflow, it will later fail serialization. + e->data.buffer_log.log_len = MIN (UINT32_MAX, log.len); + e->data.buffer_log.log = (uint8_t *) str_steal (&log); + fclose (fp); + } + + // XXX: We log failures to the global buffer, + // so the client just receives nothing if there is no log file. + + free (path); + relay_send (c); +} + +static bool +client_process_message (struct client *c, + struct msg_unpacker *r, struct relay_command_message *m) +{ + if (!relay_command_message_deserialize (m, r) + || msg_unpacker_get_available (r)) + { + log_global_error (c->ctx, "Deserialization failed, killing client"); + return false; + } + + const char *buffer_name = client_message_buffer_name (m); + struct buffer *buffer = NULL; + if (buffer_name && !(buffer = buffer_by_name (c->ctx, buffer_name))) + { + relay_prepare_error (c->ctx, m->command_seq, "Unknown buffer"); + relay_send (c); + return true; + } + + switch (m->data.command) + { + case RELAY_COMMAND_HELLO: + if (m->data.hello.version != RELAY_VERSION) + { + // TODO: This should send back an error message and shut down. + log_global_error (c->ctx, + "Protocol version mismatch, killing client"); + return false; + } + c->initialized = true; + client_resync (c); + break; + case RELAY_COMMAND_PING: + relay_prepare_response (c->ctx, m->command_seq) + ->data.command = RELAY_COMMAND_PING; + relay_send (c); + break; + case RELAY_COMMAND_ACTIVE: + reset_autoaway (c->ctx); + break; + case RELAY_COMMAND_BUFFER_COMPLETE: + client_process_buffer_complete (c, m->command_seq, buffer, + &m->data.buffer_complete); + break; + case RELAY_COMMAND_BUFFER_ACTIVATE: + buffer_activate (c->ctx, buffer); + break; + case RELAY_COMMAND_BUFFER_INPUT: + process_input (c->ctx, buffer, m->data.buffer_input.text.str); + break; + case RELAY_COMMAND_BUFFER_TOGGLE_UNIMPORTANT: + buffer_toggle_unimportant (c->ctx, buffer); + break; + case RELAY_COMMAND_BUFFER_LOG: + client_process_buffer_log (c, m->command_seq, buffer); + break; + default: + log_global_debug (c->ctx, "Unhandled client command"); + relay_prepare_error (c->ctx, m->command_seq, "Unknown command"); + relay_send (c); + } + return true; +} + +static bool +client_process_buffer (struct client *c) +{ + struct str *buf = &c->read_buffer; + size_t offset = 0; + while (true) + { + uint32_t frame_len = 0; + struct msg_unpacker r = + msg_unpacker_make (buf->str + offset, buf->len - offset); + if (!msg_unpacker_u32 (&r, &frame_len)) + break; + + r.len = MIN (r.len, sizeof frame_len + frame_len); + if (msg_unpacker_get_available (&r) < frame_len) + break; + + struct relay_command_message m = {}; + bool ok = client_process_message (c, &r, &m); + relay_command_message_free (&m); + if (!ok) + return false; + + offset += r.offset; + } + + str_remove_slice (buf, 0, offset); + return true; +} + +// --- Relay plumbing ---------------------------------------------------------- + +static bool +client_try_read (struct client *c) +{ + struct str *buf = &c->read_buffer; + ssize_t n_read; + + while ((n_read = read (c->socket_fd, buf->str + buf->len, + buf->alloc - buf->len - 1 /* null byte */)) > 0) + { + buf->len += n_read; + if (!client_process_buffer (c)) + break; + str_reserve (buf, 512); + } + + if (n_read < 0) + { + if (errno == EAGAIN || errno == EINTR) + return true; + + log_global_debug (c->ctx, + "#s: #s: #l", __func__, "read", strerror (errno)); + } + + client_kill (c); + return false; +} + +static bool +client_try_write (struct client *c) +{ + struct str *buf = &c->write_buffer; + ssize_t n_written; + + while (buf->len) + { + n_written = write (c->socket_fd, buf->str, buf->len); + if (n_written >= 0) + { + str_remove_slice (buf, 0, n_written); + continue; + } + if (errno == EAGAIN || errno == EINTR) + return true; + + log_global_debug (c->ctx, + "#s: #s: #l", __func__, "write", strerror (errno)); + client_kill (c); + return false; + } + return true; +} + +static void +on_client_ready (const struct pollfd *pfd, void *user_data) +{ + struct client *c = user_data; + if (client_try_read (c) && client_try_write (c)) + client_update_poller (c, pfd); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +relay_try_fetch_client (struct app_context *ctx, int listen_fd) +{ + // XXX: `struct sockaddr_storage' is not the most portable thing + struct sockaddr_storage peer; + socklen_t peer_len = sizeof peer; + + int fd = accept (listen_fd, (struct sockaddr *) &peer, &peer_len); + if (fd == -1) + { + if (errno == EAGAIN || errno == EWOULDBLOCK) + return false; + if (errno == EINTR) + return true; + + if (accept_error_is_transient (errno)) + { + log_global_debug (ctx, "#s: #l", "accept", strerror (errno)); + return true; + } + + log_global_error (ctx, "#s: #l", "accept", strerror (errno)); + app_context_relay_stop (ctx); + return false; + } + + hard_assert (peer_len <= sizeof peer); + set_blocking (fd, false); + set_cloexec (fd); + + // We already buffer our output, so reduce latencies. + int yes = 1; + soft_assert (setsockopt (fd, IPPROTO_TCP, TCP_NODELAY, + &yes, sizeof yes) != -1); + + struct client *c = client_new (); + c->ctx = ctx; + c->socket_fd = fd; + LIST_PREPEND (ctx->clients, c); + + c->socket_event = poller_fd_make (&c->ctx->poller, c->socket_fd); + c->socket_event.dispatcher = (poller_fd_fn) on_client_ready; + c->socket_event.user_data = c; + + client_update_poller (c, NULL); + return true; +} + +static void +on_relay_client_available (const struct pollfd *pfd, void *user_data) +{ + struct app_context *ctx = user_data; + while (relay_try_fetch_client (ctx, pfd->fd)) + ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static int +relay_listen (struct addrinfo *ai, struct error **e) +{ + int fd = socket (ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd == -1) + { + error_set (e, "socket: %s", strerror (errno)); + return -1; + } + + set_cloexec (fd); + + int yes = 1; + soft_assert (setsockopt (fd, SOL_SOCKET, SO_KEEPALIVE, + &yes, sizeof yes) != -1); + soft_assert (setsockopt (fd, SOL_SOCKET, SO_REUSEADDR, + &yes, sizeof yes) != -1); + + if (bind (fd, ai->ai_addr, ai->ai_addrlen)) + error_set (e, "bind: %s", strerror (errno)); + else if (listen (fd, 16 /* arbitrary number */)) + error_set (e, "listen: %s", strerror (errno)); + else + return fd; + + xclose (fd); + return -1; +} + +static int +relay_listen_with_context (struct app_context *ctx, struct addrinfo *ai, + struct error **e) +{ + char *address = gai_reconstruct_address (ai); + log_global_debug (ctx, "binding to `#l'", address); + + struct error *error = NULL; + int fd = relay_listen (ai, &error); + if (fd == -1) + { + error_set (e, "binding to `%s' failed: %s", address, error->message); + error_free (error); + } + free (address); + return fd; +} + +static bool +relay_start (struct app_context *ctx, char *address, struct error **e) +{ + const char *port = NULL, *host = tokenize_host_port (address, &port); + if (!port || !*port) + return error_set (e, "missing port"); + + struct addrinfo hints = {}, *result = NULL; + hints.ai_socktype = SOCK_STREAM; + hints.ai_flags = AI_PASSIVE; + + int err = getaddrinfo (*host ? host : NULL, port, &hints, &result); + if (err) + { + return error_set (e, "failed to resolve `%s', port `%s': %s: %s", + host, port, "getaddrinfo", gai_strerror (err)); + } + + // Just try the first one, disregarding IPv4/IPv6 ordering. + int fd = relay_listen_with_context (ctx, result, e); + freeaddrinfo (result); + if (fd == -1) + return false; + + set_blocking (fd, false); + + struct poller_fd *event = &ctx->relay_event; + *event = poller_fd_make (&ctx->poller, fd); + event->dispatcher = (poller_fd_fn) on_relay_client_available; + event->user_data = ctx; + + ctx->relay_fd = fd; + poller_fd_set (event, POLLIN); + return true; +} + +static void +on_config_relay_bind_change (struct config_item *item) +{ + struct app_context *ctx = item->user_data; + char *value = item->value.string.str; + app_context_relay_stop (ctx); + if (!value) + return; + + // XXX: This should perhaps be reencoded as the locale encoding. + char *address = xstrdup (value); + + struct error *e = NULL; + if (!relay_start (ctx, address, &e)) + { + log_global_error (ctx, "#s: #l", item->schema->name, e->message); + error_free (e); + } + free (address); +} + +// --- Tests ------------------------------------------------------------------- + +// The application is quite monolithic and can only be partially unit-tested. +// Locale-, terminal- and filesystem-dependent tests are also somewhat tricky. + +#ifdef TESTING + +static struct config_schema g_config_test[] = +{ + { .name = "foo", .type = CONFIG_ITEM_BOOLEAN, .default_ = "off" }, + { .name = "bar", .type = CONFIG_ITEM_INTEGER, .default_ = "1" }, + { .name = "foobar", .type = CONFIG_ITEM_STRING, .default_ = "\"x\\x01\"" }, + {} +}; + +static void +test_config (void) +{ + struct config_item *foo = config_item_object (); + config_schema_apply_to_object (g_config_test, foo, NULL); + struct config_item *root = config_item_object (); + str_map_set (&root->value.object, "top", foo); + + struct strv v = strv_make (); + dump_matching_options (root, "*foo*", &v); + hard_assert (v.len == 2); + hard_assert (!strcmp (v.vector[0], "top.foo = off")); + hard_assert (!strcmp (v.vector[1], "top.foobar = \"x\\x01\"")); + strv_free (&v); + + config_item_destroy (root); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +test_aliases (void) +{ + struct strv v = strv_make (); + expand_alias_definition ("/foo; /bar $* $$$;;;$1$2$3$4", "foo bar baz", &v); + hard_assert (v.len == 4); + hard_assert (!strcmp (v.vector[0], "/foo")); + hard_assert (!strcmp (v.vector[1], " /bar foo bar baz $;")); + hard_assert (!strcmp (v.vector[2], "")); + hard_assert (!strcmp (v.vector[3], "foobarbaz")); + strv_free (&v); +} + +static void +test_wrapping (void) +{ + static const char *message = " foo bar foobar fóóbárbáz\002 a\0031 b"; + // XXX: formatting continuation order is implementation-dependent here + // (irc_serialize_char_attrs() makes a choice in serialization) + static const char *split[] = { " foo", "bar", "foob", "ar", + "fó", "ób", "árb", "áz\x02", "\002a\0031", "\0031\002b" }; + + struct strv v = strv_make (); + hard_assert (wrap_message (message, 4, &v, NULL)); + hard_assert (v.len == N_ELEMENTS (split)); + for (size_t i = 0; i < N_ELEMENTS (split); i++) + hard_assert (!strcmp (v.vector[i], split[i])); + strv_free (&v); +} + +static void +test_utf8 (void) +{ + static const char *a[] = { "fřoo", "Fřooř", "fřOOŘ" }; + hard_assert (utf8_common_prefix (a, N_ELEMENTS (a)) == 5); + + char *cut_off = xstrdup ("ё\xD0"); + irc_sanitize_cut_off_utf8 (&cut_off); + hard_assert (!strcmp (cut_off, "ё\xEF\xBF\xBD")); + free (cut_off); +} + +int +main (int argc, char *argv[]) +{ + struct test test; + test_init (&test, argc, argv); + test_add_simple (&test, "/config", NULL, test_config); + test_add_simple (&test, "/aliases", NULL, test_aliases); + test_add_simple (&test, "/wrapping", NULL, test_wrapping); + test_add_simple (&test, "/utf8", NULL, test_utf8); + return test_run (&test); +} + +#define main main_shadowed +#endif // TESTING + +// --- Main program ------------------------------------------------------------ + +static const char *g_logo[] = +{ + "", + "\x02" PROGRAM_NAME "\x02 " PROGRAM_VERSION, + "", +}; + +static void +show_logo (struct app_context *ctx) +{ + for (size_t i = 0; i < N_ELEMENTS (g_logo); i++) + log_global_indent (ctx, "#m", g_logo[i]); +} + +static void +format_input_and_die (struct app_context *ctx) +{ + char buf[513]; + while (fgets (buf, sizeof buf, stdin)) + { + struct formatter f = formatter_make (ctx, NULL); + formatter_add (&f, "#m", buf); + formatter_flush (&f, stdout, FLUSH_OPT_NOWRAP); + formatter_free (&f); + } + exit (EXIT_SUCCESS); +} + +int +main (int argc, char *argv[]) +{ + // We include a generated file from xD including this array we don't use; + // let's just keep it there and silence the compiler warning instead + (void) g_default_replies; + + static const struct opt opts[] = + { + { 'h', "help", NULL, 0, "display this help and exit" }, + { 'V', "version", NULL, 0, "output version information and exit" }, + // This is mostly intended for previewing formatted MOTD files + { 'f', "format", NULL, OPT_LONG_ONLY, "format IRC text from stdin" }, + { 0, NULL, NULL, 0, NULL } + }; + + struct opt_handler oh = + opt_handler_make (argc, argv, opts, NULL, "Terminal-based IRC client."); + bool format_mode = false; + + int c; + while ((c = opt_handler_get (&oh)) != -1) + switch (c) + { + case 'h': + opt_handler_usage (&oh, stdout); + exit (EXIT_SUCCESS); + case 'V': + printf (PROGRAM_NAME " " PROGRAM_VERSION "\n"); + exit (EXIT_SUCCESS); + case 'f': + format_mode = true; + break; + default: + print_error ("wrong options"); + opt_handler_usage (&oh, stderr); + exit (EXIT_FAILURE); + } + if (optind != argc) + { + opt_handler_usage (&oh, stderr); + exit (EXIT_FAILURE); + } + opt_handler_free (&oh); + + // We only need to convert to and from the terminal encoding + setlocale (LC_CTYPE, ""); + + struct app_context ctx; + app_context_init (&ctx); + g_ctx = &ctx; + + init_openssl (); + + // Bootstrap configuration, so that we can access schema items at all + register_config_modules (&ctx); + config_load (&ctx.config, config_item_object ()); + + // The following part is a bit brittle because of interdependencies + init_colors (&ctx); + if (format_mode) format_input_and_die (&ctx); + init_global_buffer (&ctx); + show_logo (&ctx); + setup_signal_handlers (); + init_poller_events (&ctx); + load_configuration (&ctx); + + // At this moment we can safely call any "on_change" callbacks + config_schema_call_changed (ctx.config.root); + + // Initialize input so that we can switch to new buffers + on_refresh_prompt (&ctx); + ctx.input->add_functions = input_add_functions; + CALL_ (ctx.input, start, argv[0]); + toggle_bracketed_paste (true); + reset_autoaway (&ctx); + + // Finally, we juice the configuration for some servers to create + load_plugins (&ctx); + load_servers (&ctx); + + ctx.polling = true; + while (ctx.polling) + poller_run (&ctx.poller); + + CALL (ctx.input, stop); + + app_context_free (&ctx); + toggle_bracketed_paste (false); + free_terminal (); + return EXIT_SUCCESS; +} Binary files differdiff --git a/xD-gen-replies.awk b/xD-gen-replies.awk new file mode 100755 index 0000000..c9e8882 --- /dev/null +++ b/xD-gen-replies.awk @@ -0,0 +1,29 @@ +#!/usr/bin/awk -f +BEGIN { + # The message catalog is a by-product + msg = "xD.msg" + print "$quote \"" > msg; + print "$set 1" > msg; +} + +/^[0-9]+ *IRC_(ERR|RPL)_[A-Z]+ *".*"$/ { + match($0, /".*"/); + ids[$1] = $2; + texts[$2] = substr($0, RSTART, RLENGTH); + print $1 " " texts[$2] > msg +} + +END { + printf("enum\n{") + for (i in ids) { + if (seen_first) + printf(",") + seen_first = 1 + printf("\n\t%s = %s", ids[i], i) + } + print "\n};\n" + print "static const char *g_default_replies[] =\n{" + for (i in ids) + print "\t[" ids[i] "] = " texts[ids[i]] "," + print "};" +} diff --git a/xD-replies b/xD-replies new file mode 100644 index 0000000..fc8d6df --- /dev/null +++ b/xD-replies @@ -0,0 +1,93 @@ +1 IRC_RPL_WELCOME ":Welcome to the Internet Relay Network %s!%s@%s" +2 IRC_RPL_YOURHOST ":Your host is %s, running version %s" +3 IRC_RPL_CREATED ":This server was created %s" +4 IRC_RPL_MYINFO "%s %s %s %s" +5 IRC_RPL_ISUPPORT "%s :are supported by this server" +211 IRC_RPL_STATSLINKINFO "%s %zu %zu %zu %zu %zu %lld" +212 IRC_RPL_STATSCOMMANDS "%s %zu %zu %zu" +219 IRC_RPL_ENDOFSTATS "%c :End of STATS report" +221 IRC_RPL_UMODEIS "+%s" +242 IRC_RPL_STATSUPTIME ":Server Up %d days %d:%02d:%02d" +251 IRC_RPL_LUSERCLIENT ":There are %d users and %d services on %d servers" +252 IRC_RPL_LUSEROP "%d :operator(s) online" +253 IRC_RPL_LUSERUNKNOWN "%d :unknown connection(s)" +254 IRC_RPL_LUSERCHANNELS "%d :channels formed" +255 IRC_RPL_LUSERME ":I have %d clients and %d servers" +301 IRC_RPL_AWAY "%s :%s" +302 IRC_RPL_USERHOST ":%s" +303 IRC_RPL_ISON ":%s" +305 IRC_RPL_UNAWAY ":You are no longer marked as being away" +306 IRC_RPL_NOWAWAY ":You have been marked as being away" +311 IRC_RPL_WHOISUSER "%s %s %s * :%s" +312 IRC_RPL_WHOISSERVER "%s %s :%s" +313 IRC_RPL_WHOISOPERATOR "%s :is an IRC operator" +314 IRC_RPL_WHOWASUSER "%s %s %s * :%s" +315 IRC_RPL_ENDOFWHO "%s :End of WHO list" +317 IRC_RPL_WHOISIDLE "%s %d :seconds idle" +318 IRC_RPL_ENDOFWHOIS "%s :End of WHOIS list" +319 IRC_RPL_WHOISCHANNELS "%s :%s" +322 IRC_RPL_LIST "%s %d :%s" +323 IRC_RPL_LISTEND ":End of LIST" +324 IRC_RPL_CHANNELMODEIS "%s +%s" +329 IRC_RPL_CREATIONTIME "%s %lld" +331 IRC_RPL_NOTOPIC "%s :No topic is set" +332 IRC_RPL_TOPIC "%s :%s" +333 IRC_RPL_TOPICWHOTIME "%s %s %lld" +341 IRC_RPL_INVITING "%s %s" +346 IRC_RPL_INVITELIST "%s %s" +347 IRC_RPL_ENDOFINVITELIST "%s :End of channel invite list" +348 IRC_RPL_EXCEPTLIST "%s %s" +349 IRC_RPL_ENDOFEXCEPTLIST "%s :End of channel exception list" +351 IRC_RPL_VERSION "%s.%d %s :%s" +352 IRC_RPL_WHOREPLY "%s %s %s %s %s %s :%d %s" +353 IRC_RPL_NAMREPLY "%c %s :%s" +364 IRC_RPL_LINKS "%s %s :%d %s" +365 IRC_RPL_ENDOFLINKS "%s :End of LINKS list" +366 IRC_RPL_ENDOFNAMES "%s :End of NAMES list" +367 IRC_RPL_BANLIST "%s %s" +368 IRC_RPL_ENDOFBANLIST "%s :End of channel ban list" +369 IRC_RPL_ENDOFWHOWAS "%s :End of WHOWAS" +372 IRC_RPL_MOTD ":- %s" +375 IRC_RPL_MOTDSTART ":- %s Message of the day - " +376 IRC_RPL_ENDOFMOTD ":End of MOTD command" +391 IRC_RPL_TIME "%s :%s" +401 IRC_ERR_NOSUCHNICK "%s :No such nick/channel" +402 IRC_ERR_NOSUCHSERVER "%s :No such server" +403 IRC_ERR_NOSUCHCHANNEL "%s :No such channel" +404 IRC_ERR_CANNOTSENDTOCHAN "%s :Cannot send to channel" +406 IRC_ERR_WASNOSUCHNICK "%s :There was no such nickname" +409 IRC_ERR_NOORIGIN ":No origin specified" +410 IRC_ERR_INVALIDCAPCMD "%s :%s" +411 IRC_ERR_NORECIPIENT ":No recipient given (%s)" +412 IRC_ERR_NOTEXTTOSEND ":No text to send" +421 IRC_ERR_UNKNOWNCOMMAND "%s: Unknown command" +422 IRC_ERR_NOMOTD ":MOTD File is missing" +423 IRC_ERR_NOADMININFO "%s :No administrative info available" +431 IRC_ERR_NONICKNAMEGIVEN ":No nickname given" +432 IRC_ERR_ERRONEOUSNICKNAME "%s :Erroneous nickname" +433 IRC_ERR_NICKNAMEINUSE "%s :Nickname is already in use" +441 IRC_ERR_USERNOTINCHANNEL "%s %s :They aren't on that channel" +442 IRC_ERR_NOTONCHANNEL "%s :You're not on that channel" +443 IRC_ERR_USERONCHANNEL "%s %s :is already on channel" +445 IRC_ERR_SUMMONDISABLED ":SUMMON has been disabled" +446 IRC_ERR_USERSDISABLED ":USERS has been disabled" +451 IRC_ERR_NOTREGISTERED ":You have not registered" +461 IRC_ERR_NEEDMOREPARAMS "%s :Not enough parameters" +462 IRC_ERR_ALREADYREGISTERED ":Unauthorized command (already registered)" +467 IRC_ERR_KEYSET "%s :Channel key already set" +471 IRC_ERR_CHANNELISFULL "%s :Cannot join channel (+l)" +472 IRC_ERR_UNKNOWNMODE "%c :is unknown mode char to me for %s" +473 IRC_ERR_INVITEONLYCHAN "%s :Cannot join channel (+i)" +474 IRC_ERR_BANNEDFROMCHAN "%s :Cannot join channel (+b)" +475 IRC_ERR_BADCHANNELKEY "%s :Cannot join channel (+k)" +476 IRC_ERR_BADCHANMASK "%s :Bad Channel Mask" +481 IRC_ERR_NOPRIVILEGES ":Permission Denied- You're not an IRC operator" +482 IRC_ERR_CHANOPRIVSNEEDED "%s :You're not channel operator" +501 IRC_ERR_UMODEUNKNOWNFLAG ":Unknown MODE flag" +502 IRC_ERR_USERSDONTMATCH ":Cannot change mode for other users" +902 IRC_ERR_NICKLOCKED ":You must use a nick assigned to you" +903 IRC_RPL_SASLSUCCESS ":SASL authentication successful" +904 IRC_ERR_SASLFAIL ":SASL authentication failed" +905 IRC_ERR_SASLTOOLONG ":SASL message too long" +906 IRC_ERR_SASLABORTED ":SASL authentication aborted" +907 IRC_ERR_SASLALREADY ":You have already authenticated using SASL" @@ -0,0 +1,53 @@ +xD(1) +===== +:doctype: manpage +:manmanual: xK Manual +:mansource: xK {release-version} + +Name +---- +xD - IRC daemon + +Synopsis +-------- +*xD* [_OPTION_]... + +Description +----------- +*xD* is a basic IRC daemon for single-server networks, suitable for testing +and private use. When run without a configuration file, it will start listening +on the standard port 6667 and the "any" address. + +Options +------- +*-d*, *--debug*:: + Do not daemonize, print more information on the standard error stream + to help debug various issues. + +*-h*, *--help*:: + Display a help message and exit. + +*-V*, *--version*:: + Output version information and exit. + +*--write-default-cfg*[**=**__PATH__]:: + Write a configuration file with defaults, show its path and exit. ++ +The file will be appropriately commented. ++ +When no _PATH_ is specified, it will be created in the user's home directory, +contrary to what you might expect from a server. + +Files +----- +*xD* follows the XDG Base Directory Specification. + +_~/.config/xD/xD.conf_:: +_/etc/xdg/xD/xD.conf_:: + The daemon's configuration file. Use the *--write-default-cfg* option + to create a new one for editing. + +Reporting bugs +-------------- +Use https://git.janouch.name/p/xK to report bugs, request features, +or submit pull requests. @@ -0,0 +1,4106 @@ +/* + * xD.c: an IRC daemon + * + * Copyright (c) 2014 - 2022, Přemysl Eric Janouch <p@janouch.name> + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#include "config.h" +#define PROGRAM_NAME "xD" + +#define WANT_SYSLOG_LOGGING +#include "common.c" +#include "xD-replies.c" + +#include <nl_types.h> +#include <sys/resource.h> + +enum { PIPE_READ, PIPE_WRITE }; + +// FIXME: don't use time_t to compute time deltas + +// --- Configuration (application-specific) ------------------------------------ + +// Just get rid of the crappiest ciphers available by default +#define DEFAULT_CIPHERS "DEFAULT:!MEDIUM:!LOW" + +static struct simple_config_item g_config_table[] = +{ + { "pid_file", NULL, "Path or name of the PID file" }, + { "server_name", NULL, "Server name" }, + { "server_info", "My server", "Brief server description" }, + { "motd", NULL, "MOTD filename" }, + { "catalog", NULL, "catgets localization catalog" }, + + { "bind_host", NULL, "Address of the IRC server" }, + { "bind_port", "6667", "Port of the IRC server" }, + { "tls_cert", NULL, "Server TLS certificate (PEM)" }, + { "tls_key", NULL, "Server TLS private key (PEM)" }, + { "tls_ciphers", DEFAULT_CIPHERS, "OpenSSL cipher list" }, + + { "operators", NULL, "IRCop TLS client cert. SHA-1 fingerprints" }, + + { "max_connections", "0", "Global connection limit" }, + { "ping_interval", "180", "Interval between PINGs (sec)" }, + { NULL, NULL, NULL } +}; + +// --- Signals ----------------------------------------------------------------- + +static int g_signal_pipe[2]; ///< A pipe used to signal... signals + +/// Program termination has been requested by a signal +static volatile sig_atomic_t g_termination_requested; + +static void +sigterm_handler (int signum) +{ + (void) signum; + + g_termination_requested = true; + + int original_errno = errno; + if (write (g_signal_pipe[1], "t", 1) == -1) + soft_assert (errno == EAGAIN); + errno = original_errno; +} + +static void +setup_signal_handlers (void) +{ + if (pipe (g_signal_pipe) == -1) + exit_fatal ("%s: %s", "pipe", strerror (errno)); + + set_cloexec (g_signal_pipe[0]); + set_cloexec (g_signal_pipe[1]); + + // So that the pipe cannot overflow; it would make write() block within + // the signal handler, which is something we really don't want to happen. + // The same holds true for read(). + set_blocking (g_signal_pipe[0], false); + set_blocking (g_signal_pipe[1], false); + + signal (SIGPIPE, SIG_IGN); + + struct sigaction sa; + sa.sa_flags = SA_RESTART; + sigemptyset (&sa.sa_mask); + sa.sa_handler = sigterm_handler; + if (sigaction (SIGINT, &sa, NULL) == -1 + || sigaction (SIGTERM, &sa, NULL) == -1) + exit_fatal ("%s: %s", "sigaction", strerror (errno)); +} + +// --- Rate limiter ------------------------------------------------------------ + +struct flood_detector +{ + unsigned interval; ///< Interval for the limit + unsigned limit; ///< Maximum number of events allowed + + time_t *timestamps; ///< Timestamps of last events + unsigned pos; ///< Index of the oldest event +}; + +static void +flood_detector_init (struct flood_detector *self, + unsigned interval, unsigned limit) +{ + self->interval = interval; + self->limit = limit; + self->timestamps = xcalloc (limit + 1, sizeof *self->timestamps); + self->pos = 0; +} + +static void +flood_detector_free (struct flood_detector *self) +{ + free (self->timestamps); +} + +static bool +flood_detector_check (struct flood_detector *self) +{ + time_t now = time (NULL); + self->timestamps[self->pos++] = now; + if (self->pos > self->limit) + self->pos = 0; + + time_t begin = now - self->interval; + size_t count = 0; + for (size_t i = 0; i <= self->limit; i++) + if (self->timestamps[i] >= begin) + count++; + return count <= self->limit; +} + +// --- IRC token validation ---------------------------------------------------- + +// Use the enum only if applicable and a simple boolean isn't sufficient. + +enum validation_result +{ + VALIDATION_OK, + VALIDATION_ERROR_EMPTY, + VALIDATION_ERROR_TOO_LONG, + VALIDATION_ERROR_INVALID +}; + +// Everything as per RFC 2812 +#define IRC_MAX_NICKNAME 9 +#define IRC_MAX_HOSTNAME 63 +#define IRC_MAX_CHANNEL_NAME 50 +#define IRC_MAX_MESSAGE_LENGTH 510 + +static bool +irc_regex_match (const char *regex, const char *s) +{ + static struct str_map cache; + static bool initialized; + + if (!initialized) + { + cache = regex_cache_make (); + initialized = true; + } + + struct error *e = NULL; + bool result = regex_cache_match (&cache, regex, + REG_EXTENDED | REG_NOSUB, s, &e); + hard_assert (!e); + return result; +} + +static const char * +irc_validate_to_str (enum validation_result result) +{ + switch (result) + { + case VALIDATION_OK: return "success"; + case VALIDATION_ERROR_EMPTY: return "the value is empty"; + case VALIDATION_ERROR_INVALID: return "invalid format"; + case VALIDATION_ERROR_TOO_LONG: return "the value is too long"; + default: abort (); + } +} + +// Anything to keep it as short as possible +// "shortname" from RFC 2812 doesn't work how its author thought it would. +#define SN "[0-9A-Za-z](-*[0-9A-Za-z])*" +#define N4 "[0-9]{1,3}" +#define N6 "[0-9ABCDEFabcdef]{1,}" + +#define LE "A-Za-z" +#define SP "][\\\\`_^{|}" + +static enum validation_result +irc_validate_hostname (const char *hostname) +{ + if (!*hostname) + return VALIDATION_ERROR_EMPTY; + if (!irc_regex_match ("^" SN "(\\." SN ")*$", hostname)) + return VALIDATION_ERROR_INVALID; + if (strlen (hostname) > IRC_MAX_HOSTNAME) + return VALIDATION_ERROR_TOO_LONG; + return VALIDATION_OK; +} + +static bool +irc_is_valid_hostaddr (const char *hostaddr) +{ + if (irc_regex_match ("^" N4 "\\." N4 "\\." N4 "\\." N4 "$", hostaddr) + || irc_regex_match ("^" N6 ":" N6 ":" N6 ":" N6 ":" + N6 ":" N6 ":" N6 ":" N6 "$", hostaddr) + || irc_regex_match ("^0:0:0:0:0:(0|[Ff]{4}):" + N4 "\\." N4 "\\." N4 "\\." N4 "$", hostaddr)) + return true; + return false; +} + +// TODO: we should actually use this, though what should we do on failure? +static bool +irc_is_valid_host (const char *host) +{ + return irc_validate_hostname (host) == VALIDATION_OK + || irc_is_valid_hostaddr (host); +} + +// TODO: currently, we are almost encoding-agnostic (strings just need to be +// ASCII-compatible). We should at least have an option to enforce a specific +// encoding, such as UTF-8. Note that with Unicode we should not allow all +// character clasess and exclude the likes of \pM with the goal of enforcing +// NFC-normalized identifiers--utf8proc is a good candidate library to handle +// the categorization and validation. + +static bool +irc_is_valid_user (const char *user) +{ + return irc_regex_match ("^[^\r\n @]+$", user); +} + +static bool +irc_validate_nickname (const char *nickname) +{ + if (!*nickname) + return VALIDATION_ERROR_EMPTY; + if (!irc_regex_match ("^[" SP LE "][" SP LE "0-9-]*$", nickname)) + return VALIDATION_ERROR_INVALID; + if (strlen (nickname) > IRC_MAX_NICKNAME) + return VALIDATION_ERROR_TOO_LONG; + return VALIDATION_OK; +} + +static enum validation_result +irc_validate_channel_name (const char *channel_name) +{ + if (!*channel_name) + return VALIDATION_ERROR_EMPTY; + if (*channel_name != '#' || strpbrk (channel_name, "\7\r\n ,:")) + return VALIDATION_ERROR_INVALID; + if (strlen (channel_name) > IRC_MAX_CHANNEL_NAME) + return VALIDATION_ERROR_TOO_LONG; + return VALIDATION_OK; +} + +static bool +irc_is_valid_key (const char *key) +{ + // XXX: should be 7-bit as well but whatever + return irc_regex_match ("^[^\r\n\f\t\v ]{1,23}$", key); +} + +#undef SN +#undef N4 +#undef N6 + +#undef LE +#undef SP + +static bool +irc_is_valid_user_mask (const char *mask) +{ + return irc_regex_match ("^[^!@]+![^!@]+@[^@!]+$", mask); +} + +static bool +irc_is_valid_fingerprint (const char *fp) +{ + return irc_regex_match ("^[a-fA-F0-9]{40}$", fp); +} + +// --- Clients (equals users) -------------------------------------------------- + +#define IRC_SUPPORTED_USER_MODES "aiwros" + +enum +{ + IRC_USER_MODE_INVISIBLE = (1 << 0), + IRC_USER_MODE_RX_WALLOPS = (1 << 1), + IRC_USER_MODE_RESTRICTED = (1 << 2), + IRC_USER_MODE_OPERATOR = (1 << 3), + IRC_USER_MODE_RX_SERVER_NOTICES = (1 << 4) +}; + +enum +{ + IRC_CAP_MULTI_PREFIX = (1 << 0), + IRC_CAP_INVITE_NOTIFY = (1 << 1), + IRC_CAP_ECHO_MESSAGE = (1 << 2), + IRC_CAP_USERHOST_IN_NAMES = (1 << 3), + IRC_CAP_SERVER_TIME = (1 << 4) +}; + +struct client +{ + LIST_HEADER (struct client) + struct server_context *ctx; ///< Server context + + time_t opened; ///< When the connection was opened + size_t n_sent_messages; ///< Number of sent messages total + size_t sent_bytes; ///< Number of sent bytes total + size_t n_received_messages; ///< Number of received messages total + size_t received_bytes; ///< Number of received bytes total + + int socket_fd; ///< The TCP socket + struct str read_buffer; ///< Unprocessed input + struct str write_buffer; ///< Output yet to be sent out + + struct poller_fd socket_event; ///< The socket can be read/written to + struct poller_timer ping_timer; ///< We should send a ping + struct poller_timer timeout_timer; ///< Connection seems to be dead + struct poller_timer kill_timer; ///< Hard kill timeout + + unsigned long cap_version; ///< CAP protocol version + unsigned caps_enabled; ///< Enabled capabilities + + unsigned initialized : 1; ///< Has any data been received yet? + unsigned cap_negotiating : 1; ///< Negotiating capabilities + unsigned registered : 1; ///< The user has registered + unsigned closing_link : 1; ///< Closing link + unsigned half_closed : 1; ///< Closing link: conn. is half-closed + + unsigned ssl_rx_want_tx : 1; ///< SSL_read() wants to write + unsigned ssl_tx_want_rx : 1; ///< SSL_write() wants to read + SSL *ssl; ///< SSL connection + char *ssl_cert_fingerprint; ///< Client certificate fingerprint + + char *nickname; ///< IRC nickname (main identifier) + char *username; ///< IRC username + char *realname; ///< IRC realname (e-mail) + + char *hostname; ///< Hostname shown to the network + char *port; ///< Port of the peer as a string + char *address; ///< Full address + + unsigned mode; ///< User's mode + char *away_message; ///< Away message + time_t last_active; ///< Last PRIVMSG, to get idle time + struct str_map invites; ///< Channel invitations by operators + struct flood_detector antiflood; ///< Flood detector + + struct async_getnameinfo *gni; ///< Backwards DNS resolution + struct poller_timer gni_timer; ///< Backwards DNS resolution timeout +}; + +static struct client * +client_new (void) +{ + struct client *self = xcalloc (1, sizeof *self); + self->socket_fd = -1; + self->read_buffer = str_make (); + self->write_buffer = str_make (); + self->cap_version = 301; + // TODO: make this configurable and more fine-grained + flood_detector_init (&self->antiflood, 10, 20); + self->invites = str_map_make (NULL); + self->invites.key_xfrm = irc_strxfrm; + return self; +} + +static void +client_destroy (struct client *self) +{ + if (!soft_assert (self->socket_fd == -1)) + xclose (self->socket_fd); + if (self->ssl) + SSL_free (self->ssl); + + str_free (&self->read_buffer); + str_free (&self->write_buffer); + free (self->ssl_cert_fingerprint); + + free (self->nickname); + free (self->username); + free (self->realname); + + free (self->hostname); + free (self->port); + free (self->address); + + free (self->away_message); + flood_detector_free (&self->antiflood); + str_map_free (&self->invites); + + if (self->gni) + async_cancel (&self->gni->async); + free (self); +} + +static void client_close_link (struct client *c, const char *reason); +static void client_kill (struct client *c, const char *reason); +static void client_send (struct client *, const char *, ...) + ATTRIBUTE_PRINTF (2, 3); +static void client_cancel_timers (struct client *); +static void client_set_kill_timer (struct client *); +static void client_update_poller (struct client *, const struct pollfd *); + +// --- Channels ---------------------------------------------------------------- + +#define IRC_SUPPORTED_CHAN_MODES "ov" "beI" "imnqpst" "kl" + +enum +{ + IRC_CHAN_MODE_INVITE_ONLY = (1 << 0), + IRC_CHAN_MODE_MODERATED = (1 << 1), + IRC_CHAN_MODE_NO_OUTSIDE_MSGS = (1 << 2), + IRC_CHAN_MODE_QUIET = (1 << 3), + IRC_CHAN_MODE_PRIVATE = (1 << 4), + IRC_CHAN_MODE_SECRET = (1 << 5), + IRC_CHAN_MODE_PROTECTED_TOPIC = (1 << 6), + + IRC_CHAN_MODE_OPERATOR = (1 << 7), + IRC_CHAN_MODE_VOICE = (1 << 8) +}; + +struct channel_user +{ + LIST_HEADER (struct channel_user) + + unsigned modes; + struct client *c; +}; + +struct channel +{ + struct server_context *ctx; ///< Server context + + char *name; ///< Channel name + unsigned modes; ///< Channel modes + char *key; ///< Channel key + long user_limit; ///< User limit or -1 + time_t created; ///< Creation time + + char *topic; ///< Channel topic + char *topic_who; ///< Who set the topic + time_t topic_time; ///< When the topic was set + + struct channel_user *users; ///< Channel users + + struct strv ban_list; ///< Ban list + struct strv exception_list; ///< Exceptions from bans + struct strv invite_list; ///< Exceptions from +I +}; + +static struct channel * +channel_new (void) +{ + struct channel *self = xcalloc (1, sizeof *self); + + self->user_limit = -1; + self->topic = xstrdup (""); + + self->ban_list = strv_make (); + self->exception_list = strv_make (); + self->invite_list = strv_make (); + return self; +} + +static void +channel_delete (struct channel *self) +{ + free (self->name); + free (self->key); + free (self->topic); + free (self->topic_who); + + struct channel_user *link, *tmp; + for (link = self->users; link; link = tmp) + { + tmp = link->next; + free (link); + } + + strv_free (&self->ban_list); + strv_free (&self->exception_list); + strv_free (&self->invite_list); + + free (self); +} + +static char * +channel_get_mode (struct channel *self, bool disclose_secrets) +{ + struct str mode = str_make (); + unsigned m = self->modes; + if (m & IRC_CHAN_MODE_INVITE_ONLY) str_append_c (&mode, 'i'); + if (m & IRC_CHAN_MODE_MODERATED) str_append_c (&mode, 'm'); + if (m & IRC_CHAN_MODE_NO_OUTSIDE_MSGS) str_append_c (&mode, 'n'); + if (m & IRC_CHAN_MODE_QUIET) str_append_c (&mode, 'q'); + if (m & IRC_CHAN_MODE_PRIVATE) str_append_c (&mode, 'p'); + if (m & IRC_CHAN_MODE_SECRET) str_append_c (&mode, 's'); + if (m & IRC_CHAN_MODE_PROTECTED_TOPIC) str_append_c (&mode, 't'); + + if (self->user_limit != -1) str_append_c (&mode, 'l'); + if (self->key) str_append_c (&mode, 'k'); + + // XXX: is it correct to split it? Try it on an existing implementation. + if (disclose_secrets) + { + if (self->user_limit != -1) + str_append_printf (&mode, " %ld", self->user_limit); + if (self->key) + str_append_printf (&mode, " %s", self->key); + } + return str_steal (&mode); +} + +static struct channel_user * +channel_get_user (const struct channel *chan, const struct client *c) +{ + for (struct channel_user *iter = chan->users; iter; iter = iter->next) + if (iter->c == c) + return iter; + return NULL; +} + +static struct channel_user * +channel_add_user (struct channel *chan, struct client *c) +{ + struct channel_user *link = xcalloc (1, sizeof *link); + link->c = c; + LIST_PREPEND (chan->users, link); + return link; +} + +static void +channel_remove_user (struct channel *chan, struct channel_user *user) +{ + LIST_UNLINK (chan->users, user); + free (user); +} + +static size_t +channel_user_count (const struct channel *chan) +{ + size_t result = 0; + for (struct channel_user *iter = chan->users; iter; iter = iter->next) + result++; + return result; +} + +// --- IRC server context ------------------------------------------------------ + +struct whowas_info +{ + char *nickname; ///< IRC nickname + char *username; ///< IRC username + char *realname; ///< IRC realname + char *hostname; ///< Hostname shown to the network +}; + +struct whowas_info * +whowas_info_new (struct client *c) +{ + struct whowas_info *self = xmalloc (sizeof *self); + self->nickname = xstrdup (c->nickname); + self->username = xstrdup (c->username); + self->realname = xstrdup (c->realname); + self->hostname = xstrdup (c->hostname); + return self; +} + +static void +whowas_info_destroy (struct whowas_info *self) +{ + free (self->nickname); + free (self->username); + free (self->realname); + free (self->hostname); + free (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct irc_command +{ + const char *name; + bool requires_registration; + void (*handler) (const struct irc_message *, struct client *); + + size_t n_received; ///< Number of commands received + size_t bytes_received; ///< Number of bytes received total +}; + +struct server_context +{ + int *listen_fds; ///< Listening socket FD's + struct poller_fd *listen_events; ///< New connections available + size_t listen_len; ///< Number of listening sockets + size_t listen_alloc; ///< How many we've allocated + + time_t started; ///< When has the server been started + + SSL_CTX *ssl_ctx; ///< SSL context + struct client *clients; ///< Clients + unsigned n_clients; ///< Current number of connections + + struct str_map users; ///< Maps nicknames to clients + struct str_map channels; ///< Maps channel names to data + struct str_map handlers; ///< Message handlers + struct str_map cap_handlers; ///< CAP message handlers + + struct str_map whowas; ///< WHOWAS registry + + struct poller poller; ///< Manages polled description + struct poller_timer quit_timer; ///< Quit timeout timer + bool quitting; ///< User requested quitting + bool polling; ///< The event loop is running + + struct poller_fd signal_event; ///< Got a signal + + struct str_map config; ///< Server configuration + char *server_name; ///< Our server name + unsigned ping_interval; ///< Ping interval in seconds + unsigned max_connections; ///< Max. connections allowed or 0 + struct strv motd; ///< MOTD (none if empty) + nl_catd catalog; ///< Message catalog for server msgs + struct str_map operators; ///< TLS cert. fingerprints for IRCops +}; + +static void +on_irc_quit_timeout (void *user_data) +{ + struct server_context *ctx = user_data; + struct client *iter, *next; + for (iter = ctx->clients; iter; iter = next) + { + next = iter->next; + // irc_initiate_quit() has already unregistered the client + client_kill (iter, "Shutting down"); + } +} + +static void +server_context_init (struct server_context *self) +{ + memset (self, 0, sizeof *self); + + self->users = str_map_make (NULL); + self->users.key_xfrm = irc_strxfrm; + self->channels = str_map_make ((str_map_free_fn) channel_delete); + self->channels.key_xfrm = irc_strxfrm; + self->handlers = str_map_make (NULL); + self->handlers.key_xfrm = irc_strxfrm; + self->cap_handlers = str_map_make (NULL); + self->cap_handlers.key_xfrm = irc_strxfrm; + + self->whowas = str_map_make ((str_map_free_fn) whowas_info_destroy); + self->whowas.key_xfrm = irc_strxfrm; + + poller_init (&self->poller); + self->quit_timer = poller_timer_make (&self->poller); + self->quit_timer.dispatcher = on_irc_quit_timeout; + self->quit_timer.user_data = self; + + self->config = str_map_make (free); + simple_config_load_defaults (&self->config, g_config_table); + + self->motd = strv_make (); + self->catalog = (nl_catd) -1; + self->operators = str_map_make (NULL); + // The regular irc_strxfrm() is sufficient for fingerprints + self->operators.key_xfrm = irc_strxfrm; +} + +static void +server_context_free (struct server_context *self) +{ + str_map_free (&self->config); + + for (size_t i = 0; i < self->listen_len; i++) + { + poller_fd_reset (&self->listen_events[i]); + xclose (self->listen_fds[i]); + } + free (self->listen_fds); + free (self->listen_events); + + hard_assert (!self->clients); + if (self->ssl_ctx) + SSL_CTX_free (self->ssl_ctx); + + free (self->server_name); + str_map_free (&self->users); + str_map_free (&self->channels); + str_map_free (&self->handlers); + str_map_free (&self->cap_handlers); + str_map_free (&self->whowas); + poller_free (&self->poller); + + strv_free (&self->motd); + if (self->catalog != (nl_catd) -1) + catclose (self->catalog); + str_map_free (&self->operators); +} + +static const char * +irc_get_text (struct server_context *ctx, int id, const char *def) +{ + if (!soft_assert (def != NULL)) + def = ""; + if (ctx->catalog == (nl_catd) -1) + return def; + return catgets (ctx->catalog, 1, id, def); +} + +static void +irc_try_finish_quit (struct server_context *ctx) +{ + if (!ctx->n_clients && ctx->quitting) + { + poller_timer_reset (&ctx->quit_timer); + ctx->polling = false; + } +} + +static void +irc_initiate_quit (struct server_context *ctx) +{ + print_status ("shutting down"); + + for (size_t i = 0; i < ctx->listen_len; i++) + { + poller_fd_reset (&ctx->listen_events[i]); + xclose (ctx->listen_fds[i]); + } + ctx->listen_len = 0; + + for (struct client *iter = ctx->clients; iter; iter = iter->next) + if (!iter->closing_link) + client_close_link (iter, "Shutting down"); + + ctx->quitting = true; + poller_timer_set (&ctx->quit_timer, 5000); + irc_try_finish_quit (ctx); +} + +static struct channel * +irc_channel_create (struct server_context *ctx, const char *name) +{ + struct channel *chan = channel_new (); + chan->ctx = ctx; + chan->name = xstrdup (name); + chan->created = time (NULL); + str_map_set (&ctx->channels, name, chan); + return chan; +} + +static void +irc_channel_destroy_if_empty (struct server_context *ctx, struct channel *chan) +{ + if (!chan->users) + str_map_set (&ctx->channels, chan->name, NULL); +} + +static void +irc_send_to_roommates (struct client *c, const char *message) +{ + struct str_map targets = str_map_make (NULL); + targets.key_xfrm = irc_strxfrm; + + struct str_map_iter iter = str_map_iter_make (&c->ctx->channels); + struct channel *chan; + while ((chan = str_map_iter_next (&iter))) + { + if (chan->modes & IRC_CHAN_MODE_QUIET + || !channel_get_user (chan, c)) + continue; + for (struct channel_user *iter = chan->users; iter; iter = iter->next) + str_map_set (&targets, iter->c->nickname, iter->c); + } + + iter = str_map_iter_make (&targets); + struct client *target; + while ((target = str_map_iter_next (&iter))) + if (target != c) + client_send (target, "%s", message); + str_map_free (&targets); +} + +// --- Clients (continued) ----------------------------------------------------- + +static void +client_mode_to_str (unsigned m, struct str *out) +{ + if (m & IRC_USER_MODE_INVISIBLE) str_append_c (out, 'i'); + if (m & IRC_USER_MODE_RX_WALLOPS) str_append_c (out, 'w'); + if (m & IRC_USER_MODE_RESTRICTED) str_append_c (out, 'r'); + if (m & IRC_USER_MODE_OPERATOR) str_append_c (out, 'o'); + if (m & IRC_USER_MODE_RX_SERVER_NOTICES) str_append_c (out, 's'); +} + +static char * +client_get_mode (struct client *self) +{ + struct str mode = str_make (); + if (self->away_message) + str_append_c (&mode, 'a'); + client_mode_to_str (self->mode, &mode); + return str_steal (&mode); +} + +static void +client_send_str (struct client *c, const struct str *s) +{ + hard_assert (!c->closing_link); + + size_t old_sendq = c->write_buffer.len; + + // So far there's only one message tag we use, so we can do it simple; + // note that a 1024-character limit applies to messages with tags on + if (c->caps_enabled & IRC_CAP_SERVER_TIME) + { + long milliseconds; char buf[32]; struct tm tm; + time_t now = unixtime_msec (&milliseconds); + if (soft_assert (strftime (buf, sizeof buf, + "%Y-%m-%dT%T", gmtime_r (&now, &tm)))) + str_append_printf (&c->write_buffer, + "@time=%s.%03ldZ ", buf, milliseconds); + } + + // TODO: kill the connection above some "SendQ" threshold (careful!) + str_append_data (&c->write_buffer, s->str, + MIN (s->len, IRC_MAX_MESSAGE_LENGTH)); + str_append (&c->write_buffer, "\r\n"); + client_update_poller (c, NULL); + + // Technically we haven't sent it yet but that's a minor detail + c->n_sent_messages++; + c->sent_bytes += c->write_buffer.len - old_sendq; +} + +static void +client_send (struct client *c, const char *format, ...) +{ + struct str tmp = str_make (); + + va_list ap; + va_start (ap, format); + str_append_vprintf (&tmp, format, ap); + va_end (ap); + + client_send_str (c, &tmp); + str_free (&tmp); +} + +static void +client_add_to_whowas (struct client *c) +{ + // Only keeping one entry for each nickname + // TODO: make sure this list doesn't get too long, for example by + // putting them in a linked list ordered by time + str_map_set (&c->ctx->whowas, c->nickname, whowas_info_new (c)); +} + +static void +client_unregister (struct client *c, const char *reason) +{ + if (!c->registered) + return; + + char *message = xstrdup_printf (":%s!%s@%s QUIT :%s", + c->nickname, c->username, c->hostname, reason); + irc_send_to_roommates (c, message); + free (message); + + struct str_map_unset_iter iter = + str_map_unset_iter_make (&c->ctx->channels); + struct channel *chan; + while ((chan = str_map_unset_iter_next (&iter))) + { + struct channel_user *user; + if (!(user = channel_get_user (chan, c))) + continue; + channel_remove_user (chan, user); + irc_channel_destroy_if_empty (c->ctx, chan); + } + str_map_unset_iter_free (&iter); + + client_add_to_whowas (c); + + str_map_set (&c->ctx->users, c->nickname, NULL); + cstr_set (&c->nickname, NULL); + c->registered = false; +} + +static void +client_kill (struct client *c, const char *reason) +{ + struct server_context *ctx = c->ctx; + client_unregister (c, reason ? reason : "Client exited"); + + if (c->address) + // Only log the event if address resolution has finished + print_debug ("closed connection to %s (%s)", c->address, + reason ? reason : ""); + + if (c->ssl) + // Note that we might have already called this once, but that is fine + (void) SSL_shutdown (c->ssl); + + xclose (c->socket_fd); + c->socket_fd = -1; + + // We don't fork any children, this is okay + c->socket_event.closed = true; + poller_fd_reset (&c->socket_event); + client_cancel_timers (c); + + LIST_UNLINK (ctx->clients, c); + ctx->n_clients--; + client_destroy (c); + + irc_try_finish_quit (ctx); +} + +static void +client_close_link (struct client *c, const char *reason) +{ + // Let's just cut the connection, the client can try again later. + // We also want to avoid accidentally setting poller events before + // address resolution has finished. + if (!c->initialized) + { + client_kill (c, reason); + return; + } + if (!soft_assert (!c->closing_link)) + return; + + // We push an `ERROR' message to the write buffer and let the poller send + // it, with some arbitrary timeout. The `closing_link' state makes sure + // that a/ we ignore any successive messages, and b/ that the connection + // is killed after the write buffer is transferred and emptied. + client_send (c, "ERROR :Closing Link: %s[%s] (%s)", + c->nickname ? c->nickname : "*", + c->hostname /* TODO host IP? */, reason); + c->closing_link = true; + + client_unregister (c, reason); + client_set_kill_timer (c); +} + +static bool +client_in_mask_list (const struct client *c, const struct strv *mask) +{ + char *client = xstrdup_printf ("%s!%s@%s", + c->nickname, c->username, c->hostname); + bool result = false; + for (size_t i = 0; i < mask->len; i++) + if (!irc_fnmatch (mask->vector[i], client)) + { + result = true; + break; + } + free (client); + return result; +} + +static char * +client_get_ssl_cert_fingerprint (struct client *c) +{ + if (!c->ssl) + return NULL; + + X509 *peer_cert = SSL_get_peer_certificate (c->ssl); + if (!peer_cert) + return NULL; + + int cert_len = i2d_X509 (peer_cert, NULL); + if (cert_len < 0) + return NULL; + + unsigned char cert[cert_len], *p = cert; + if (i2d_X509 (peer_cert, &p) < 0) + return NULL; + + unsigned char hash[SHA_DIGEST_LENGTH]; + SHA1 (cert, cert_len, hash); + + struct str fingerprint = str_make (); + for (size_t i = 0; i < sizeof hash; i++) + str_append_printf (&fingerprint, "%02x", hash[i]); + return str_steal (&fingerprint); +} + +// --- Timers ------------------------------------------------------------------ + +static void +client_cancel_timers (struct client *c) +{ + poller_timer_reset (&c->kill_timer); + poller_timer_reset (&c->timeout_timer); + poller_timer_reset (&c->ping_timer); + poller_timer_reset (&c->gni_timer); +} + +static void +client_set_timer (struct client *c, + struct poller_timer *timer, unsigned interval) +{ + client_cancel_timers (c); + poller_timer_set (timer, interval * 1000); +} + +static void +on_client_kill_timer (struct client *c) +{ + hard_assert (!c->initialized || c->closing_link); + client_kill (c, NULL); +} + +static void +client_set_kill_timer (struct client *c) +{ + client_set_timer (c, &c->kill_timer, c->ctx->ping_interval); +} + +static void +on_client_timeout_timer (struct client *c) +{ + char *reason = xstrdup_printf + ("Ping timeout: >%u seconds", c->ctx->ping_interval); + client_close_link (c, reason); + free (reason); +} + +static void +on_client_ping_timer (struct client *c) +{ + hard_assert (!c->closing_link); + client_send (c, "PING :%s", c->ctx->server_name); + client_set_timer (c, &c->timeout_timer, c->ctx->ping_interval); +} + +static void +client_set_ping_timer (struct client *c) +{ + client_set_timer (c, &c->ping_timer, c->ctx->ping_interval); +} + +// --- IRC command handling ---------------------------------------------------- + +static void +irc_make_reply (struct client *c, int id, va_list ap, struct str *output) +{ + str_append_printf (output, ":%s %03d %s ", + c->ctx->server_name, id, c->nickname ? c->nickname : "*"); + str_append_vprintf (output, + irc_get_text (c->ctx, id, g_default_replies[id]), ap); +} + +// XXX: this way we cannot typecheck the arguments, so we must be careful +static void +irc_send_reply (struct client *c, int id, ...) +{ + struct str reply = str_make (); + + va_list ap; + va_start (ap, id); + irc_make_reply (c, id, ap, &reply); + va_end (ap); + + client_send_str (c, &reply); + str_free (&reply); +} + +/// Send a space-separated list of words across as many replies as needed +static void +irc_send_reply_vector (struct client *c, int id, char **items, ...) +{ + struct str common = str_make (); + + va_list ap; + va_start (ap, items); + irc_make_reply (c, id, ap, &common); + va_end (ap); + + // We always send at least one message (there might be a client that + // expects us to send this message at least once) + do + { + struct str reply = str_make (); + str_append_str (&reply, &common); + + // If not even a single item fits in the limit (which may happen, + // in theory) it just gets cropped. We could also skip it. + if (*items) + str_append (&reply, *items++); + + // Append as many items as fits in a single message + while (*items + && reply.len + 1 + strlen (*items) <= IRC_MAX_MESSAGE_LENGTH) + str_append_printf (&reply, " %s", *items++); + + client_send_str (c, &reply); + str_free (&reply); + } + while (*items); + str_free (&common); +} + +#define RETURN_WITH_REPLY(c, ...) \ + BLOCK_START \ + irc_send_reply ((c), __VA_ARGS__); \ + return; \ + BLOCK_END + +static void +irc_send_motd (struct client *c) +{ + struct server_context *ctx = c->ctx; + if (!ctx->motd.len) + RETURN_WITH_REPLY (c, IRC_ERR_NOMOTD); + + irc_send_reply (c, IRC_RPL_MOTDSTART, ctx->server_name); + for (size_t i = 0; i < ctx->motd.len; i++) + irc_send_reply (c, IRC_RPL_MOTD, ctx->motd.vector[i]); + irc_send_reply (c, IRC_RPL_ENDOFMOTD); +} + +static void +irc_send_lusers (struct client *c) +{ + int n_users = 0, n_services = 0, n_opers = 0, n_unknown = 0; + for (struct client *link = c->ctx->clients; link; link = link->next) + { + if (link->registered) + n_users++; + else + n_unknown++; + if (link->mode & IRC_USER_MODE_OPERATOR) + n_opers++; + } + + int n_channels = 0; + struct str_map_iter iter = str_map_iter_make (&c->ctx->channels); + struct channel *chan; + while ((chan = str_map_iter_next (&iter))) + if (!(chan->modes & IRC_CHAN_MODE_SECRET) + || channel_get_user (chan, c)) + n_channels++; + + irc_send_reply (c, IRC_RPL_LUSERCLIENT, + n_users, n_services, 1 /* servers total */); + if (n_opers) + irc_send_reply (c, IRC_RPL_LUSEROP, n_opers); + if (n_unknown) + irc_send_reply (c, IRC_RPL_LUSERUNKNOWN, n_unknown); + if (n_channels) + irc_send_reply (c, IRC_RPL_LUSERCHANNELS, n_channels); + irc_send_reply (c, IRC_RPL_LUSERME, + n_users + n_services + n_unknown, 0 /* peer servers */); +} + +static bool +irc_is_this_me (struct server_context *ctx, const char *target) +{ + // Target servers can also be matched by their users + return !irc_fnmatch (target, ctx->server_name) + || str_map_find (&ctx->users, target); +} + +static void +irc_send_isupport (struct client *c) +{ + // Only # channels, +e supported, +I supported, unlimited arguments to MODE + irc_send_reply (c, IRC_RPL_ISUPPORT, "CHANTYPES=# EXCEPTS INVEX MODES" + " TARGMAX=WHOIS:,LIST:,NAMES:,PRIVMSG:1,NOTICE:1,KICK:" + " NICKLEN=" XSTRINGIFY (IRC_MAX_NICKNAME) + " CHANNELLEN=" XSTRINGIFY (IRC_MAX_CHANNEL_NAME)); +} + +static void +irc_try_finish_registration (struct client *c) +{ + struct server_context *ctx = c->ctx; + if (!c->nickname || !c->username || !c->realname) + return; + if (c->registered || c->cap_negotiating) + return; + + c->registered = true; + irc_send_reply (c, IRC_RPL_WELCOME, c->nickname, c->username, c->hostname); + + irc_send_reply (c, IRC_RPL_YOURHOST, ctx->server_name, PROGRAM_VERSION); + // The purpose of this message eludes me + irc_send_reply (c, IRC_RPL_CREATED, __DATE__); + irc_send_reply (c, IRC_RPL_MYINFO, ctx->server_name, PROGRAM_VERSION, + IRC_SUPPORTED_USER_MODES, IRC_SUPPORTED_CHAN_MODES); + + irc_send_isupport (c); + irc_send_lusers (c); + irc_send_motd (c); + + char *mode = client_get_mode (c); + if (*mode) + client_send (c, ":%s MODE %s :+%s", c->nickname, c->nickname, mode); + free (mode); + + hard_assert (c->ssl_cert_fingerprint == NULL); + if ((c->ssl_cert_fingerprint = client_get_ssl_cert_fingerprint (c))) + client_send (c, ":%s NOTICE %s :" + "Your TLS client certificate fingerprint is %s", + ctx->server_name, c->nickname, c->ssl_cert_fingerprint); + + str_map_set (&ctx->whowas, c->nickname, NULL); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// IRCv3 capability negotiation. See http://ircv3.org for details. + +struct irc_cap_args +{ + const char *subcommand; ///< The subcommand being processed + const char *full_params; ///< Whole parameter string + struct strv params; ///< Split parameters + const char *target; ///< Target parameter for replies +}; + +static struct +{ + unsigned flag; ///< Flag + const char *name; ///< Name of the capability +} +irc_cap_table[] = +{ + { IRC_CAP_MULTI_PREFIX, "multi-prefix" }, + { IRC_CAP_INVITE_NOTIFY, "invite-notify" }, + { IRC_CAP_ECHO_MESSAGE, "echo-message" }, + { IRC_CAP_USERHOST_IN_NAMES, "userhost-in-names" }, + { IRC_CAP_SERVER_TIME, "server-time" }, +}; + +static void +irc_handle_cap_ls (struct client *c, struct irc_cap_args *a) +{ + if (a->params.len == 1 + && !xstrtoul (&c->cap_version, a->params.vector[0], 10)) + irc_send_reply (c, IRC_ERR_INVALIDCAPCMD, + a->subcommand, "Ignoring invalid protocol version number"); + + c->cap_negotiating = true; + client_send (c, ":%s CAP %s LS :multi-prefix invite-notify echo-message" + " userhost-in-names server-time", c->ctx->server_name, a->target); +} + +static void +irc_handle_cap_list (struct client *c, struct irc_cap_args *a) +{ + struct strv caps = strv_make (); + for (size_t i = 0; i < N_ELEMENTS (irc_cap_table); i++) + if (c->caps_enabled & irc_cap_table[i].flag) + strv_append (&caps, irc_cap_table[i].name); + + char *caps_str = strv_join (&caps, " "); + strv_free (&caps); + client_send (c, ":%s CAP %s LIST :%s", + c->ctx->server_name, a->target, caps_str); + free (caps_str); +} + +static unsigned +irc_decode_capability (const char *name) +{ + for (size_t i = 0; i < N_ELEMENTS (irc_cap_table); i++) + if (!strcmp (irc_cap_table[i].name, name)) + return irc_cap_table[i].flag; + return 0; +} + +static void +irc_handle_cap_req (struct client *c, struct irc_cap_args *a) +{ + c->cap_negotiating = true; + + unsigned new_caps = c->caps_enabled; + bool success = true; + for (size_t i = 0; i < a->params.len; i++) + { + bool removing = false; + const char *name = a->params.vector[i]; + if (*name == '-') + { + removing = true; + name++; + } + + unsigned cap; + if (!(cap = irc_decode_capability (name))) + success = false; + else if (removing) + new_caps &= ~cap; + else + new_caps |= cap; + } + + if (success) + { + c->caps_enabled = new_caps; + client_send (c, ":%s CAP %s ACK :%s", + c->ctx->server_name, a->target, a->full_params); + } + else + client_send (c, ":%s CAP %s NAK :%s", + c->ctx->server_name, a->target, a->full_params); +} + +static void +irc_handle_cap_ack (struct client *c, struct irc_cap_args *a) +{ + if (a->params.len) + irc_send_reply (c, IRC_ERR_INVALIDCAPCMD, + a->subcommand, "No acknowledgable capabilities supported"); +} + +static void +irc_handle_cap_end (struct client *c, struct irc_cap_args *a) +{ + (void) a; + + c->cap_negotiating = false; + irc_try_finish_registration (c); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct irc_cap_command +{ + const char *name; + void (*handler) (struct client *, struct irc_cap_args *); +}; + +static void +irc_register_cap_handlers (struct server_context *ctx) +{ + static const struct irc_cap_command cap_handlers[] = + { + { "LS", irc_handle_cap_ls }, + { "LIST", irc_handle_cap_list }, + { "REQ", irc_handle_cap_req }, + { "ACK", irc_handle_cap_ack }, + { "END", irc_handle_cap_end }, + }; + + for (size_t i = 0; i < N_ELEMENTS (cap_handlers); i++) + { + const struct irc_cap_command *cmd = &cap_handlers[i]; + str_map_set (&ctx->cap_handlers, cmd->name, (void *) cmd); + } +} + +static void +irc_handle_cap (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + struct irc_cap_args args; + args.target = c->nickname ? c->nickname : "*"; + args.subcommand = msg->params.vector[0]; + args.full_params = ""; + args.params = strv_make (); + + if (msg->params.len > 1) + { + args.full_params = msg->params.vector[1]; + cstr_split (args.full_params, " ", true, &args.params); + } + + struct irc_cap_command *cmd = + str_map_find (&c->ctx->cap_handlers, args.subcommand); + if (!cmd) + irc_send_reply (c, IRC_ERR_INVALIDCAPCMD, + args.subcommand, "Invalid CAP subcommand"); + else + cmd->handler (c, &args); + + strv_free (&args.params); +} + +static void +irc_handle_pass (const struct irc_message *msg, struct client *c) +{ + if (c->registered) + irc_send_reply (c, IRC_ERR_ALREADYREGISTERED); + else if (msg->params.len < 1) + irc_send_reply (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + // We have TLS client certificates for this purpose; ignoring +} + +static void +irc_handle_nick (const struct irc_message *msg, struct client *c) +{ + struct server_context *ctx = c->ctx; + + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NONICKNAMEGIVEN); + + const char *nickname = msg->params.vector[0]; + if (irc_validate_nickname (nickname) != VALIDATION_OK) + RETURN_WITH_REPLY (c, IRC_ERR_ERRONEOUSNICKNAME, nickname); + + struct client *client = str_map_find (&ctx->users, nickname); + if (client && client != c) + RETURN_WITH_REPLY (c, IRC_ERR_NICKNAMEINUSE, nickname); + + // Nothing to do here, let's not annoy roommates + if (c->nickname && !strcmp (c->nickname, nickname)) + return; + + if (c->registered) + { + client_add_to_whowas (c); + + char *message = xstrdup_printf (":%s!%s@%s NICK :%s", + c->nickname, c->username, c->hostname, nickname); + irc_send_to_roommates (c, message); + client_send (c, "%s", message); + free (message); + } + + // Release the old nickname and allocate a new one + if (c->nickname) + str_map_set (&ctx->users, c->nickname, NULL); + + cstr_set (&c->nickname, xstrdup (nickname)); + str_map_set (&ctx->users, nickname, c); + + irc_try_finish_registration (c); +} + +static void +irc_handle_user (const struct irc_message *msg, struct client *c) +{ + if (c->registered) + RETURN_WITH_REPLY (c, IRC_ERR_ALREADYREGISTERED); + if (msg->params.len < 4) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + const char *username = msg->params.vector[0]; + const char *mode = msg->params.vector[1]; + const char *realname = msg->params.vector[3]; + + // Unfortunately the protocol doesn't give us any means of rejecting it + if (!irc_is_valid_user (username)) + username = "xxx"; + + cstr_set (&c->username, xstrdup (username)); + cstr_set (&c->realname, xstrdup (realname)); + c->mode = 0; + + unsigned long m; + if (xstrtoul (&m, mode, 10)) + { + if (m & 4) c->mode |= IRC_USER_MODE_RX_WALLOPS; + if (m & 8) c->mode |= IRC_USER_MODE_INVISIBLE; + } + + irc_try_finish_registration (c); +} + +static void +irc_handle_userhost (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + struct str reply = str_make (); + for (size_t i = 0; i < 5 && i < msg->params.len; i++) + { + const char *nick = msg->params.vector[i]; + struct client *target = str_map_find (&c->ctx->users, nick); + if (!target) + continue; + + if (i) + str_append_c (&reply, ' '); + str_append (&reply, nick); + if (target->mode & IRC_USER_MODE_OPERATOR) + str_append_c (&reply, '*'); + str_append_printf (&reply, "=%c%s@%s", + target->away_message ? '-' : '+', + target->username, target->hostname); + } + irc_send_reply (c, IRC_RPL_USERHOST, reply.str); + str_free (&reply); +} + +static void +irc_handle_lusers (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len > 1 && !irc_is_this_me (c->ctx, msg->params.vector[1])) + irc_send_reply (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[1]); + else + irc_send_lusers (c); +} + +static void +irc_handle_motd (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len > 0 && !irc_is_this_me (c->ctx, msg->params.vector[0])) + irc_send_reply (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[0]); + else + irc_send_motd (c); +} + +static void +irc_handle_ping (const struct irc_message *msg, struct client *c) +{ + // XXX: the RFC is pretty incomprehensible about the exact usage + if (msg->params.len > 1 && !irc_is_this_me (c->ctx, msg->params.vector[1])) + irc_send_reply (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[1]); + else if (msg->params.len < 1) + irc_send_reply (c, IRC_ERR_NOORIGIN); + else + client_send (c, ":%s PONG :%s", + c->ctx->server_name, msg->params.vector[0]); +} + +static void +irc_handle_pong (const struct irc_message *msg, struct client *c) +{ + // We are the only server, so we don't have to care too much + if (msg->params.len < 1) + irc_send_reply (c, IRC_ERR_NOORIGIN); + else + // Set a new timer to send another PING + client_set_ping_timer (c); +} + +static void +irc_handle_quit (const struct irc_message *msg, struct client *c) +{ + char *reason = xstrdup_printf ("Quit: %s", + msg->params.len > 0 ? msg->params.vector[0] : c->nickname); + client_close_link (c, reason); + free (reason); +} + +static void +irc_handle_time (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len > 0 && !irc_is_this_me (c->ctx, msg->params.vector[0])) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[0]); + + char buf[32] = ""; + time_t now = time (NULL); + struct tm tm; + strftime (buf, sizeof buf, "%a %b %d %Y %T", localtime_r (&now, &tm)); + irc_send_reply (c, IRC_RPL_TIME, c->ctx->server_name, buf); +} + +static void +irc_handle_version (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len > 0 && !irc_is_this_me (c->ctx, msg->params.vector[0])) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[0]); + + irc_send_reply (c, IRC_RPL_VERSION, PROGRAM_VERSION, g_debug_mode, + c->ctx->server_name, PROGRAM_NAME " " PROGRAM_VERSION); + irc_send_isupport (c); +} + +static void +irc_channel_multicast (struct channel *chan, const char *message, + struct client *except) +{ + for (struct channel_user *iter = chan->users; iter; iter = iter->next) + if (iter->c != except) + client_send (iter->c, "%s", message); +} + +static bool +irc_modify_mode (unsigned *mask, unsigned mode, bool add) +{ + unsigned orig = *mask; + if (add) + *mask |= mode; + else + *mask &= ~mode; + return *mask != orig; +} + +static void +irc_update_user_mode (struct client *c, unsigned new_mode) +{ + unsigned old_mode = c->mode; + c->mode = new_mode; + + unsigned added = new_mode & ~old_mode; + unsigned removed = old_mode & ~new_mode; + + struct str diff = str_make (); + if (added) + { + str_append_c (&diff, '+'); + client_mode_to_str (added, &diff); + } + if (removed) + { + str_append_c (&diff, '-'); + client_mode_to_str (removed, &diff); + } + + if (diff.len) + client_send (c, ":%s MODE %s :%s", + c->nickname, c->nickname, diff.str); + str_free (&diff); +} + +static void +irc_handle_user_mode_change (struct client *c, const char *mode_string) +{ + unsigned new_mode = c->mode; + bool adding = true; + + while (*mode_string) + switch (*mode_string++) + { + case '+': adding = true; break; + case '-': adding = false; break; + + case 'a': + // Ignore, the client should use AWAY + break; + case 'i': + irc_modify_mode (&new_mode, IRC_USER_MODE_INVISIBLE, adding); + break; + case 'w': + irc_modify_mode (&new_mode, IRC_USER_MODE_RX_WALLOPS, adding); + break; + case 'r': + // It's not possible to un-restrict yourself + if (adding) + new_mode |= IRC_USER_MODE_RESTRICTED; + break; + case 'o': + if (!adding) + new_mode &= ~IRC_USER_MODE_OPERATOR; + else if (c->ssl_cert_fingerprint + && str_map_find (&c->ctx->operators, c->ssl_cert_fingerprint)) + new_mode |= IRC_USER_MODE_OPERATOR; + else + client_send (c, ":%s NOTICE %s :Either you're not using an TLS" + " client certificate, or the fingerprint doesn't match", + c->ctx->server_name, c->nickname); + break; + case 's': + irc_modify_mode (&new_mode, IRC_USER_MODE_RX_SERVER_NOTICES, adding); + break; + default: + RETURN_WITH_REPLY (c, IRC_ERR_UMODEUNKNOWNFLAG); + } + irc_update_user_mode (c, new_mode); +} + +static void +irc_send_channel_list (struct client *c, const char *channel_name, + const struct strv *list, int reply, int end_reply) +{ + for (size_t i = 0; i < list->len; i++) + irc_send_reply (c, reply, channel_name, list->vector[i]); + irc_send_reply (c, end_reply, channel_name); +} + +static char * +irc_check_expand_user_mask (const char *mask) +{ + struct str result = str_make (); + str_append (&result, mask); + + // Make sure it is a complete mask + if (!strchr (result.str, '!')) + str_append (&result, "!*"); + if (!strchr (result.str, '@')) + str_append (&result, "@*"); + + // And validate whatever the result is + if (!irc_is_valid_user_mask (result.str)) + { + str_free (&result); + return NULL; + } + return str_steal (&result); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Channel MODE command handling. This is by far the worst command to implement +// from the whole RFC; don't blame me if it doesn't work exactly as expected. + +struct mode_processor +{ + // Inputs to set after initialization: + + char **params; ///< Mode string parameters + + struct client *c; ///< Who does the changes + struct channel *channel; ///< The channel we're modifying + struct channel_user *user; ///< Presence of the client in the chan + + // Internals: + + bool adding; ///< Currently adding modes + char mode_char; ///< Currently processed mode char + + struct str added; ///< Added modes + struct str removed; ///< Removed modes + + struct strv added_params; ///< Params for added modes + struct strv removed_params; ///< Params for removed modes + + struct str *output; ///< "added" or "removed" + struct strv *output_params; ///< Similarly for "*_params" +}; + +static struct mode_processor +mode_processor_make (void) +{ + return (struct mode_processor) + { + .added = str_make (), .added_params = strv_make (), + .removed = str_make (), .removed_params = strv_make (), + }; +} + +static void +mode_processor_free (struct mode_processor *self) +{ + str_free (&self->added); + str_free (&self->removed); + + strv_free (&self->added_params); + strv_free (&self->removed_params); +} + +static const char * +mode_processor_next_param (struct mode_processor *self) +{ + if (!*self->params) + return NULL; + return *self->params++; +} + +static bool +mode_processor_check_operator (struct mode_processor *self) +{ + if ((self->user && (self->user->modes & IRC_CHAN_MODE_OPERATOR)) + || (self->c->mode & IRC_USER_MODE_OPERATOR)) + return true; + + irc_send_reply (self->c, IRC_ERR_CHANOPRIVSNEEDED, self->channel->name); + return false; +} + +static void +mode_processor_do_user (struct mode_processor *self, int mode) +{ + const char *target = mode_processor_next_param (self); + if (!mode_processor_check_operator (self) || !target) + return; + + struct client *client; + struct channel_user *target_user; + if (!(client = str_map_find (&self->c->ctx->users, target))) + irc_send_reply (self->c, IRC_ERR_NOSUCHNICK, target); + else if (!(target_user = channel_get_user (self->channel, client))) + irc_send_reply (self->c, IRC_ERR_USERNOTINCHANNEL, + target, self->channel->name); + else if (irc_modify_mode (&target_user->modes, mode, self->adding)) + { + str_append_c (self->output, self->mode_char); + strv_append (self->output_params, client->nickname); + } +} + +static bool +mode_processor_do_chan (struct mode_processor *self, int mode) +{ + if (!mode_processor_check_operator (self) + || !irc_modify_mode (&self->channel->modes, mode, self->adding)) + return false; + + str_append_c (self->output, self->mode_char); + return true; +} + +static void +mode_processor_do_chan_remove + (struct mode_processor *self, char mode_char, int mode) +{ + if (self->adding + && irc_modify_mode (&self->channel->modes, mode, false)) + str_append_c (&self->removed, mode_char); +} + +static void +mode_processor_do_list (struct mode_processor *self, + struct strv *list, int list_msg, int end_msg) +{ + const char *target = mode_processor_next_param (self); + if (!target) + { + if (self->adding) + irc_send_channel_list (self->c, self->channel->name, + list, list_msg, end_msg); + return; + } + + if (!mode_processor_check_operator (self)) + return; + + char *mask = irc_check_expand_user_mask (target); + if (!mask) + return; + + size_t i; + for (i = 0; i < list->len; i++) + if (!irc_strcmp (list->vector[i], mask)) + break; + + bool found = i != list->len; + if (found != self->adding) + { + if (self->adding) + strv_append (list, mask); + else + strv_remove (list, i); + + str_append_c (self->output, self->mode_char); + strv_append (self->output_params, mask); + } + free (mask); +} + +static void +mode_processor_do_key (struct mode_processor *self) +{ + const char *target = mode_processor_next_param (self); + if (!mode_processor_check_operator (self) || !target) + return; + + if (!self->adding) + { + if (!self->channel->key || irc_strcmp (target, self->channel->key)) + return; + + str_append_c (&self->removed, self->mode_char); + strv_append (&self->removed_params, self->channel->key); + cstr_set (&self->channel->key, NULL); + } + else if (!irc_is_valid_key (target)) + // TODO: we should notify the user somehow + return; + else if (self->channel->key) + irc_send_reply (self->c, IRC_ERR_KEYSET, self->channel->name); + else + { + self->channel->key = xstrdup (target); + str_append_c (&self->added, self->mode_char); + strv_append (&self->added_params, self->channel->key); + } +} + +static void +mode_processor_do_limit (struct mode_processor *self) +{ + if (!mode_processor_check_operator (self)) + return; + + const char *target; + if (!self->adding) + { + if (self->channel->user_limit == -1) + return; + + self->channel->user_limit = -1; + str_append_c (&self->removed, self->mode_char); + } + else if ((target = mode_processor_next_param (self))) + { + unsigned long x; + if (xstrtoul (&x, target, 10) && x > 0 && x <= LONG_MAX) + { + self->channel->user_limit = x; + str_append_c (&self->added, self->mode_char); + strv_append (&self->added_params, target); + } + } +} + +static bool +mode_processor_step (struct mode_processor *self, char mode_char) +{ + switch ((self->mode_char = mode_char)) + { + case '+': + self->adding = true; + self->output = &self->added; + self->output_params = &self->added_params; + break; + case '-': + self->adding = false; + self->output = &self->removed; + self->output_params = &self->removed_params; + break; + +#define USER(mode) mode_processor_do_user (self, (mode)) +#define CHAN(mode) mode_processor_do_chan (self, (mode)) + + case 'o': USER (IRC_CHAN_MODE_OPERATOR); break; + case 'v': USER (IRC_CHAN_MODE_VOICE); break; + + case 'i': CHAN (IRC_CHAN_MODE_INVITE_ONLY); break; + case 'm': CHAN (IRC_CHAN_MODE_MODERATED); break; + case 'n': CHAN (IRC_CHAN_MODE_NO_OUTSIDE_MSGS); break; + case 'q': CHAN (IRC_CHAN_MODE_QUIET); break; + case 't': CHAN (IRC_CHAN_MODE_PROTECTED_TOPIC); break; + + case 'p': + if (CHAN (IRC_CHAN_MODE_PRIVATE)) + mode_processor_do_chan_remove (self, 's', IRC_CHAN_MODE_SECRET); + break; + case 's': + if (CHAN (IRC_CHAN_MODE_SECRET)) + mode_processor_do_chan_remove (self, 'p', IRC_CHAN_MODE_PRIVATE); + break; + +#undef USER +#undef CHAN + + case 'b': + mode_processor_do_list (self, &self->channel->ban_list, + IRC_RPL_BANLIST, IRC_RPL_ENDOFBANLIST); + break; + case 'e': + mode_processor_do_list (self, &self->channel->exception_list, + IRC_RPL_EXCEPTLIST, IRC_RPL_ENDOFEXCEPTLIST); + break; + case 'I': + mode_processor_do_list (self, &self->channel->invite_list, + IRC_RPL_INVITELIST, IRC_RPL_ENDOFINVITELIST); + break; + + case 'k': + mode_processor_do_key (self); + break; + case 'l': + mode_processor_do_limit (self); + break; + + default: + // It's not safe to continue, results could be undesired + irc_send_reply (self->c, IRC_ERR_UNKNOWNMODE, + mode_char, self->channel->name); + return false; + } + return true; +} + +static void +irc_handle_chan_mode_change + (struct client *c, struct channel *chan, char *params[]) +{ + struct mode_processor p = mode_processor_make (); + p.params = params; + p.channel = chan; + p.c = c; + p.user = channel_get_user (chan, c); + + const char *mode_string; + while ((mode_string = mode_processor_next_param (&p))) + { + mode_processor_step (&p, '+'); + while (*mode_string) + if (!mode_processor_step (&p, *mode_string++)) + goto done_processing; + } + + // TODO: limit to three changes with parameter per command +done_processing: + if (p.added.len || p.removed.len) + { + struct str message = str_make (); + str_append_printf (&message, ":%s!%s@%s MODE %s ", + p.c->nickname, p.c->username, p.c->hostname, + p.channel->name); + if (p.added.len) + str_append_printf (&message, "+%s", p.added.str); + if (p.removed.len) + str_append_printf (&message, "-%s", p.removed.str); + for (size_t i = 0; i < p.added_params.len; i++) + str_append_printf (&message, " %s", p.added_params.vector[i]); + for (size_t i = 0; i < p.removed_params.len; i++) + str_append_printf (&message, " %s", p.removed_params.vector[i]); + irc_channel_multicast (p.channel, message.str, NULL); + str_free (&message); + } + mode_processor_free (&p); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +irc_handle_mode (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + const char *target = msg->params.vector[0]; + struct client *client = str_map_find (&c->ctx->users, target); + struct channel *chan = str_map_find (&c->ctx->channels, target); + + if (client) + { + if (irc_strcmp (target, c->nickname)) + RETURN_WITH_REPLY (c, IRC_ERR_USERSDONTMATCH); + + if (msg->params.len < 2) + { + char *mode = client_get_mode (client); + irc_send_reply (c, IRC_RPL_UMODEIS, mode); + free (mode); + } + else + irc_handle_user_mode_change (c, msg->params.vector[1]); + } + else if (chan) + { + if (msg->params.len < 2) + { + char *mode = channel_get_mode (chan, channel_get_user (chan, c)); + irc_send_reply (c, IRC_RPL_CHANNELMODEIS, target, mode); + irc_send_reply (c, IRC_RPL_CREATIONTIME, + target, (long long) chan->created); + free (mode); + } + else + irc_handle_chan_mode_change (c, chan, &msg->params.vector[1]); + } + else + irc_send_reply (c, IRC_ERR_NOSUCHNICK, target); +} + +static void +irc_handle_user_message (const struct irc_message *msg, struct client *c, + const char *command, bool allow_away_reply) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NORECIPIENT, msg->command); + if (msg->params.len < 2 || !*msg->params.vector[1]) + RETURN_WITH_REPLY (c, IRC_ERR_NOTEXTTOSEND); + + const char *target = msg->params.vector[0]; + const char *text = msg->params.vector[1]; + struct client *client = str_map_find (&c->ctx->users, target); + if (client) + { + client_send (client, ":%s!%s@%s %s %s :%s", + c->nickname, c->username, c->hostname, command, target, text); + if (allow_away_reply && client->away_message) + irc_send_reply (c, IRC_RPL_AWAY, target, client->away_message); + + // Acknowledging a message from the client to itself would be silly + if (client != c && (c->caps_enabled & IRC_CAP_ECHO_MESSAGE)) + client_send (c, ":%s!%s@%s %s %s :%s", + c->nickname, c->username, c->hostname, command, target, text); + return; + } + + struct channel *chan = str_map_find (&c->ctx->channels, target); + if (chan) + { + struct channel_user *user = channel_get_user (chan, c); + if ((chan->modes & IRC_CHAN_MODE_NO_OUTSIDE_MSGS) && !user) + RETURN_WITH_REPLY (c, IRC_ERR_CANNOTSENDTOCHAN, target); + if ((chan->modes & IRC_CHAN_MODE_MODERATED) && (!user || + !(user->modes & (IRC_CHAN_MODE_VOICE | IRC_CHAN_MODE_OPERATOR)))) + RETURN_WITH_REPLY (c, IRC_ERR_CANNOTSENDTOCHAN, target); + if (client_in_mask_list (c, &chan->ban_list) + && !client_in_mask_list (c, &chan->exception_list)) + RETURN_WITH_REPLY (c, IRC_ERR_CANNOTSENDTOCHAN, target); + + char *message = xstrdup_printf (":%s!%s@%s %s %s :%s", + c->nickname, c->username, c->hostname, command, target, text); + irc_channel_multicast (chan, message, + (c->caps_enabled & IRC_CAP_ECHO_MESSAGE) ? NULL : c); + free (message); + return; + } + + irc_send_reply (c, IRC_ERR_NOSUCHNICK, target); +} + +static void +irc_handle_privmsg (const struct irc_message *msg, struct client *c) +{ + irc_handle_user_message (msg, c, "PRIVMSG", true); + // Let's not care too much about success or failure + c->last_active = time (NULL); +} + +static void +irc_handle_notice (const struct irc_message *msg, struct client *c) +{ + irc_handle_user_message (msg, c, "NOTICE", false); +} + +static void +irc_send_rpl_list (struct client *c, const struct channel *chan) +{ + int visible = 0; + for (struct channel_user *user = chan->users; + user; user = user->next) + // XXX: maybe we should skip IRC_USER_MODE_INVISIBLE + visible++; + + irc_send_reply (c, IRC_RPL_LIST, chan->name, visible, chan->topic); +} + +static void +irc_handle_list (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len > 1 && !irc_is_this_me (c->ctx, msg->params.vector[1])) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[1]); + + struct channel *chan; + if (msg->params.len == 0) + { + struct str_map_iter iter = str_map_iter_make (&c->ctx->channels); + while ((chan = str_map_iter_next (&iter))) + if (!(chan->modes & (IRC_CHAN_MODE_PRIVATE | IRC_CHAN_MODE_SECRET)) + || channel_get_user (chan, c)) + irc_send_rpl_list (c, chan); + } + else + { + struct strv channels = strv_make (); + cstr_split (msg->params.vector[0], ",", true, &channels); + for (size_t i = 0; i < channels.len; i++) + if ((chan = str_map_find (&c->ctx->channels, channels.vector[i])) + && (!(chan->modes & IRC_CHAN_MODE_SECRET) + || channel_get_user (chan, c))) + irc_send_rpl_list (c, chan); + strv_free (&channels); + } + irc_send_reply (c, IRC_RPL_LISTEND); +} + +static void +irc_append_prefixes (struct client *c, struct channel_user *user, + struct str *output) +{ + struct str prefixes = str_make (); + if (user->modes & IRC_CHAN_MODE_OPERATOR) str_append_c (&prefixes, '@'); + if (user->modes & IRC_CHAN_MODE_VOICE) str_append_c (&prefixes, '+'); + + if (prefixes.len) + { + if (c->caps_enabled & IRC_CAP_MULTI_PREFIX) + str_append (output, prefixes.str); + else + str_append_c (output, prefixes.str[0]); + } + str_free (&prefixes); +} + +static char * +irc_make_rpl_namreply_item + (struct client *c, struct client *target, struct channel_user *user) +{ + struct str result = str_make (); + + if (user) + irc_append_prefixes (c, user, &result); + + str_append (&result, target->nickname); + if (c->caps_enabled & IRC_CAP_USERHOST_IN_NAMES) + str_append_printf (&result, + "!%s@%s", target->username, target->hostname); + return str_steal (&result); +} + +static void +irc_send_rpl_namreply (struct client *c, const struct channel *chan, + struct str_map *used_nicks) +{ + char type = '='; + if (chan->modes & IRC_CHAN_MODE_SECRET) + type = '@'; + else if (chan->modes & IRC_CHAN_MODE_PRIVATE) + type = '*'; + + bool on_channel = channel_get_user (chan, c); + struct strv nicks = strv_make (); + for (struct channel_user *iter = chan->users; iter; iter = iter->next) + { + if (!on_channel && (iter->c->mode & IRC_USER_MODE_INVISIBLE)) + continue; + if (used_nicks) + str_map_set (used_nicks, iter->c->nickname, (void *) 1); + strv_append_owned (&nicks, + irc_make_rpl_namreply_item (c, iter->c, iter)); + } + + irc_send_reply_vector (c, IRC_RPL_NAMREPLY, + nicks.vector, type, chan->name, ""); + strv_free (&nicks); +} + +static void +irc_send_disassociated_names (struct client *c, struct str_map *used) +{ + struct strv nicks = strv_make (); + struct str_map_iter iter = str_map_iter_make (&c->ctx->users); + struct client *target; + while ((target = str_map_iter_next (&iter))) + { + if ((target->mode & IRC_USER_MODE_INVISIBLE) + || str_map_find (used, target->nickname)) + continue; + strv_append_owned (&nicks, + irc_make_rpl_namreply_item (c, target, NULL)); + } + + if (nicks.len) + irc_send_reply_vector (c, IRC_RPL_NAMREPLY, + nicks.vector, '*', "*", ""); + strv_free (&nicks); +} + +static void +irc_handle_names (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len > 1 && !irc_is_this_me (c->ctx, msg->params.vector[1])) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[1]); + + struct channel *chan; + if (msg->params.len == 0) + { + struct str_map used = str_map_make (NULL); + used.key_xfrm = irc_strxfrm; + + struct str_map_iter iter = str_map_iter_make (&c->ctx->channels); + while ((chan = str_map_iter_next (&iter))) + if (!(chan->modes & (IRC_CHAN_MODE_PRIVATE | IRC_CHAN_MODE_SECRET)) + || channel_get_user (chan, c)) + irc_send_rpl_namreply (c, chan, &used); + + // Also send all visible users we haven't listed yet + irc_send_disassociated_names (c, &used); + str_map_free (&used); + + irc_send_reply (c, IRC_RPL_ENDOFNAMES, "*"); + } + else + { + struct strv channels = strv_make (); + cstr_split (msg->params.vector[0], ",", true, &channels); + for (size_t i = 0; i < channels.len; i++) + if ((chan = str_map_find (&c->ctx->channels, channels.vector[i])) + && (!(chan->modes & IRC_CHAN_MODE_SECRET) + || channel_get_user (chan, c))) + { + irc_send_rpl_namreply (c, chan, NULL); + irc_send_reply (c, IRC_RPL_ENDOFNAMES, channels.vector[i]); + } + strv_free (&channels); + } +} + +static void +irc_send_rpl_whoreply (struct client *c, const struct channel *chan, + const struct client *target) +{ + struct str chars = str_make (); + str_append_c (&chars, target->away_message ? 'G' : 'H'); + if (target->mode & IRC_USER_MODE_OPERATOR) + str_append_c (&chars, '*'); + + struct channel_user *user; + if (chan && (user = channel_get_user (chan, target))) + irc_append_prefixes (c, user, &chars); + + irc_send_reply (c, IRC_RPL_WHOREPLY, chan ? chan->name : "*", + target->username, target->hostname, target->ctx->server_name, + target->nickname, chars.str, 0 /* hop count */, target->realname); + str_free (&chars); +} + +static void +irc_match_send_rpl_whoreply (struct client *c, struct client *target, + const char *mask) +{ + bool is_roommate = false; + struct str_map_iter iter = str_map_iter_make (&c->ctx->channels); + struct channel *chan; + while ((chan = str_map_iter_next (&iter))) + if (channel_get_user (chan, target) && channel_get_user (chan, c)) + { + is_roommate = true; + break; + } + if ((target->mode & IRC_USER_MODE_INVISIBLE) && !is_roommate) + return; + + if (irc_fnmatch (mask, target->hostname) + && irc_fnmatch (mask, target->nickname) + && irc_fnmatch (mask, target->realname) + && irc_fnmatch (mask, c->ctx->server_name)) + return; + + // Try to find a channel they're on that's visible to us + struct channel *user_chan = NULL; + iter = str_map_iter_make (&c->ctx->channels); + while ((chan = str_map_iter_next (&iter))) + if (channel_get_user (chan, target) + && (!(chan->modes & (IRC_CHAN_MODE_PRIVATE | IRC_CHAN_MODE_SECRET)) + || channel_get_user (chan, c))) + { + user_chan = chan; + break; + } + irc_send_rpl_whoreply (c, user_chan, target); +} + +static void +irc_handle_who (const struct irc_message *msg, struct client *c) +{ + bool only_ops = msg->params.len > 1 && !strcmp (msg->params.vector[1], "o"); + + const char *shown_mask = msg->params.vector[0], *used_mask; + if (!shown_mask) + used_mask = shown_mask = "*"; + else if (!strcmp (shown_mask, "0")) + used_mask = "*"; + else + used_mask = shown_mask; + + struct channel *chan; + if ((chan = str_map_find (&c->ctx->channels, used_mask))) + { + bool on_chan = !!channel_get_user (chan, c); + if (on_chan || !(chan->modes & IRC_CHAN_MODE_SECRET)) + for (struct channel_user *iter = chan->users; + iter; iter = iter->next) + { + if ((on_chan || !(iter->c->mode & IRC_USER_MODE_INVISIBLE)) + && (!only_ops || (iter->c->mode & IRC_USER_MODE_OPERATOR))) + irc_send_rpl_whoreply (c, chan, iter->c); + } + } + else + { + struct str_map_iter iter = str_map_iter_make (&c->ctx->users); + struct client *target; + while ((target = str_map_iter_next (&iter))) + if (!only_ops || (target->mode & IRC_USER_MODE_OPERATOR)) + irc_match_send_rpl_whoreply (c, target, used_mask); + } + irc_send_reply (c, IRC_RPL_ENDOFWHO, shown_mask); +} + +static void +irc_send_whois_reply (struct client *c, const struct client *target) +{ + const char *nick = target->nickname; + irc_send_reply (c, IRC_RPL_WHOISUSER, nick, + target->username, target->hostname, target->realname); + irc_send_reply (c, IRC_RPL_WHOISSERVER, nick, target->ctx->server_name, + str_map_find (&c->ctx->config, "server_info")); + if (target->mode & IRC_USER_MODE_OPERATOR) + irc_send_reply (c, IRC_RPL_WHOISOPERATOR, nick); + irc_send_reply (c, IRC_RPL_WHOISIDLE, nick, + (int) (time (NULL) - target->last_active)); + if (target->away_message) + irc_send_reply (c, IRC_RPL_AWAY, nick, target->away_message); + + struct strv channels = strv_make (); + + struct str_map_iter iter = str_map_iter_make (&c->ctx->channels); + struct channel *chan; + struct channel_user *channel_user; + while ((chan = str_map_iter_next (&iter))) + if ((channel_user = channel_get_user (chan, target)) + && (!(chan->modes & (IRC_CHAN_MODE_PRIVATE | IRC_CHAN_MODE_SECRET)) + || channel_get_user (chan, c))) + { + struct str item = str_make (); + if (channel_user->modes & IRC_CHAN_MODE_OPERATOR) + str_append_c (&item, '@'); + else if (channel_user->modes & IRC_CHAN_MODE_VOICE) + str_append_c (&item, '+'); + str_append (&item, chan->name); + strv_append_owned (&channels, str_steal (&item)); + } + + irc_send_reply_vector (c, IRC_RPL_WHOISCHANNELS, channels.vector, nick, ""); + strv_free (&channels); + + irc_send_reply (c, IRC_RPL_ENDOFWHOIS, nick); +} + +static void +irc_handle_whois (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + if (msg->params.len > 1 && !irc_is_this_me (c->ctx, msg->params.vector[0])) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[0]); + + struct strv masks = strv_make (); + const char *masks_str = msg->params.vector[msg->params.len > 1]; + cstr_split (masks_str, ",", true, &masks); + for (size_t i = 0; i < masks.len; i++) + { + const char *mask = masks.vector[i]; + struct client *target; + if (!strpbrk (mask, "*?")) + { + if (!(target = str_map_find (&c->ctx->users, mask))) + irc_send_reply (c, IRC_ERR_NOSUCHNICK, mask); + else + irc_send_whois_reply (c, target); + } + else + { + struct str_map_iter iter = str_map_iter_make (&c->ctx->users); + bool found = false; + while ((target = str_map_iter_next (&iter))) + if (!irc_fnmatch (mask, target->nickname)) + { + irc_send_whois_reply (c, target); + found = true; + } + if (!found) + irc_send_reply (c, IRC_ERR_NOSUCHNICK, mask); + } + } + strv_free (&masks); +} + +static void +irc_handle_whowas (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + if (msg->params.len > 2 && !irc_is_this_me (c->ctx, msg->params.vector[2])) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[2]); + // The "count" parameter is ignored, we only store one entry for a nick + + struct strv nicks = strv_make (); + cstr_split (msg->params.vector[0], ",", true, &nicks); + + for (size_t i = 0; i < nicks.len; i++) + { + const char *nick = nicks.vector[i]; + struct whowas_info *info = str_map_find (&c->ctx->whowas, nick); + if (!info) + irc_send_reply (c, IRC_ERR_WASNOSUCHNICK, nick); + else + { + irc_send_reply (c, IRC_RPL_WHOWASUSER, nick, + info->username, info->hostname, info->realname); + irc_send_reply (c, IRC_RPL_WHOISSERVER, nick, c->ctx->server_name, + str_map_find (&c->ctx->config, "server_info")); + } + irc_send_reply (c, IRC_RPL_ENDOFWHOWAS, nick); + } + strv_free (&nicks); +} + +static void +irc_send_rpl_topic (struct client *c, struct channel *chan) +{ + if (!*chan->topic) + irc_send_reply (c, IRC_RPL_NOTOPIC, chan->name); + else + { + irc_send_reply (c, IRC_RPL_TOPIC, chan->name, chan->topic); + irc_send_reply (c, IRC_RPL_TOPICWHOTIME, + chan->name, chan->topic_who, (long long) chan->topic_time); + } +} + +static void +irc_handle_topic (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + const char *target = msg->params.vector[0]; + struct channel *chan = str_map_find (&c->ctx->channels, target); + if (!chan) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHCHANNEL, target); + + if (msg->params.len < 2) + { + irc_send_rpl_topic (c, chan); + return; + } + + struct channel_user *user = channel_get_user (chan, c); + if (!user) + RETURN_WITH_REPLY (c, IRC_ERR_NOTONCHANNEL, target); + + if ((chan->modes & IRC_CHAN_MODE_PROTECTED_TOPIC) + && !(user->modes & IRC_CHAN_MODE_OPERATOR)) + RETURN_WITH_REPLY (c, IRC_ERR_CHANOPRIVSNEEDED, target); + + cstr_set (&chan->topic, xstrdup (msg->params.vector[1])); + cstr_set (&chan->topic_who, xstrdup_printf + ("%s!%s@%s", c->nickname, c->username, c->hostname)); + chan->topic_time = time (NULL); + + char *message = xstrdup_printf (":%s!%s@%s TOPIC %s :%s", + c->nickname, c->username, c->hostname, target, chan->topic); + irc_channel_multicast (chan, message, NULL); + free (message); +} + +static void +irc_try_part (struct client *c, const char *channel_name, const char *reason) +{ + if (!reason) + reason = c->nickname; + + struct channel *chan; + if (!(chan = str_map_find (&c->ctx->channels, channel_name))) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHCHANNEL, channel_name); + + struct channel_user *user; + if (!(user = channel_get_user (chan, c))) + RETURN_WITH_REPLY (c, IRC_ERR_NOTONCHANNEL, channel_name); + + char *message = xstrdup_printf (":%s!%s@%s PART %s :%s", + c->nickname, c->username, c->hostname, channel_name, reason); + if (!(chan->modes & IRC_CHAN_MODE_QUIET)) + irc_channel_multicast (chan, message, NULL); + else + client_send (c, "%s", message); + free (message); + + channel_remove_user (chan, user); + irc_channel_destroy_if_empty (c->ctx, chan); +} + +static void +irc_part_all_channels (struct client *c) +{ + // We have to be careful here, the channel might get destroyed + struct str_map_unset_iter iter = + str_map_unset_iter_make (&c->ctx->channels); + + struct channel *chan; + while ((chan = str_map_unset_iter_next (&iter))) + if (channel_get_user (chan, c)) + irc_try_part (c, chan->name, NULL); + str_map_unset_iter_free (&iter); +} + +static void +irc_handle_part (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + const char *reason = msg->params.len > 1 ? msg->params.vector[1] : NULL; + struct strv channels = strv_make (); + cstr_split (msg->params.vector[0], ",", true, &channels); + for (size_t i = 0; i < channels.len; i++) + irc_try_part (c, channels.vector[i], reason); + strv_free (&channels); +} + +static void +irc_try_kick (struct client *c, const char *channel_name, const char *nick, + const char *reason) +{ + struct channel *chan; + if (!(chan = str_map_find (&c->ctx->channels, channel_name))) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHCHANNEL, channel_name); + + struct channel_user *user; + if (!(user = channel_get_user (chan, c))) + RETURN_WITH_REPLY (c, IRC_ERR_NOTONCHANNEL, channel_name); + if (!(user->modes & IRC_CHAN_MODE_OPERATOR)) + RETURN_WITH_REPLY (c, IRC_ERR_CHANOPRIVSNEEDED, channel_name); + + struct client *client; + if (!(client = str_map_find (&c->ctx->users, nick)) + || !(user = channel_get_user (chan, client))) + RETURN_WITH_REPLY (c, IRC_ERR_USERNOTINCHANNEL, nick, channel_name); + + char *message = xstrdup_printf (":%s!%s@%s KICK %s %s :%s", + c->nickname, c->username, c->hostname, channel_name, nick, reason); + if (!(chan->modes & IRC_CHAN_MODE_QUIET)) + irc_channel_multicast (chan, message, NULL); + else + client_send (c, "%s", message); + free (message); + + channel_remove_user (chan, user); + irc_channel_destroy_if_empty (c->ctx, chan); +} + +static void +irc_handle_kick (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 2) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + const char *reason = c->nickname; + if (msg->params.len > 2) + reason = msg->params.vector[2]; + + struct strv channels = strv_make (); + cstr_split (msg->params.vector[0], ",", true, &channels); + struct strv users = strv_make (); + cstr_split (msg->params.vector[1], ",", true, &users); + + if (channels.len == 1) + for (size_t i = 0; i < users.len; i++) + irc_try_kick (c, channels.vector[0], users.vector[i], reason); + else + for (size_t i = 0; i < channels.len && i < users.len; i++) + irc_try_kick (c, channels.vector[i], users.vector[i], reason); + + strv_free (&channels); + strv_free (&users); +} + +static void +irc_send_invite_notifications + (struct channel *chan, struct client *c, struct client *target) +{ + for (struct channel_user *iter = chan->users; iter; iter = iter->next) + if (iter->c != target && iter->c->caps_enabled & IRC_CAP_INVITE_NOTIFY) + client_send (iter->c, ":%s!%s@%s INVITE %s %s", + c->nickname, c->username, c->hostname, + target->nickname, chan->name); +} + +static void +irc_handle_invite (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 2) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + const char *target = msg->params.vector[0]; + const char *channel_name = msg->params.vector[1]; + + struct client *client = str_map_find (&c->ctx->users, target); + if (!client) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHNICK, target); + + struct channel *chan = str_map_find (&c->ctx->channels, channel_name); + if (chan) + { + struct channel_user *inviting_user; + if (!(inviting_user = channel_get_user (chan, c))) + RETURN_WITH_REPLY (c, IRC_ERR_NOTONCHANNEL, channel_name); + if (channel_get_user (chan, client)) + RETURN_WITH_REPLY (c, IRC_ERR_USERONCHANNEL, target, channel_name); + + if ((inviting_user->modes & IRC_CHAN_MODE_OPERATOR)) + str_map_set (&client->invites, channel_name, (void *) 1); + else if ((chan->modes & IRC_CHAN_MODE_INVITE_ONLY)) + RETURN_WITH_REPLY (c, IRC_ERR_CHANOPRIVSNEEDED, channel_name); + + // It's not specified when and how we should send out invite-notify + if (chan->modes & IRC_CHAN_MODE_INVITE_ONLY) + irc_send_invite_notifications (chan, c, client); + } + + client_send (client, ":%s!%s@%s INVITE %s %s", + c->nickname, c->username, c->hostname, client->nickname, channel_name); + if (client->away_message) + irc_send_reply (c, IRC_RPL_AWAY, + client->nickname, client->away_message); + irc_send_reply (c, IRC_RPL_INVITING, client->nickname, channel_name); +} + +static void +irc_try_join (struct client *c, const char *channel_name, const char *key) +{ + struct channel *chan = str_map_find (&c->ctx->channels, channel_name); + unsigned user_mode = 0; + if (!chan) + { + if (irc_validate_channel_name (channel_name) != VALIDATION_OK) + RETURN_WITH_REPLY (c, IRC_ERR_BADCHANMASK, channel_name); + chan = irc_channel_create (c->ctx, channel_name); + user_mode = IRC_CHAN_MODE_OPERATOR; + } + else if (channel_get_user (chan, c)) + return; + + bool invited_by_chanop = !!str_map_find (&c->invites, channel_name); + if ((chan->modes & IRC_CHAN_MODE_INVITE_ONLY) + && !client_in_mask_list (c, &chan->invite_list) + && !invited_by_chanop) + RETURN_WITH_REPLY (c, IRC_ERR_INVITEONLYCHAN, channel_name); + if (chan->key && (!key || strcmp (key, chan->key))) + RETURN_WITH_REPLY (c, IRC_ERR_BADCHANNELKEY, channel_name); + if (chan->user_limit != -1 + && channel_user_count (chan) >= (size_t) chan->user_limit) + RETURN_WITH_REPLY (c, IRC_ERR_CHANNELISFULL, channel_name); + if (client_in_mask_list (c, &chan->ban_list) + && !client_in_mask_list (c, &chan->exception_list) + && !invited_by_chanop) + RETURN_WITH_REPLY (c, IRC_ERR_BANNEDFROMCHAN, channel_name); + + // Destroy any invitation as there's no other way to get rid of it + str_map_set (&c->invites, channel_name, NULL); + + channel_add_user (chan, c)->modes = user_mode; + + char *message = xstrdup_printf (":%s!%s@%s JOIN %s", + c->nickname, c->username, c->hostname, channel_name); + if (!(chan->modes & IRC_CHAN_MODE_QUIET)) + irc_channel_multicast (chan, message, NULL); + else + client_send (c, "%s", message); + free (message); + + irc_send_rpl_topic (c, chan); + irc_send_rpl_namreply (c, chan, NULL); + irc_send_reply (c, IRC_RPL_ENDOFNAMES, chan->name); +} + +static void +irc_handle_join (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + if (!strcmp (msg->params.vector[0], "0")) + { + irc_part_all_channels (c); + return; + } + + struct strv channels = strv_make (); + cstr_split (msg->params.vector[0], ",", true, &channels); + struct strv keys = strv_make (); + if (msg->params.len > 1) + cstr_split (msg->params.vector[1], ",", true, &keys); + + for (size_t i = 0; i < channels.len; i++) + irc_try_join (c, channels.vector[i], + i < keys.len ? keys.vector[i] : NULL); + + strv_free (&channels); + strv_free (&keys); +} + +static void +irc_handle_summon (const struct irc_message *msg, struct client *c) +{ + (void) msg; + irc_send_reply (c, IRC_ERR_SUMMONDISABLED); +} + +static void +irc_handle_users (const struct irc_message *msg, struct client *c) +{ + (void) msg; + irc_send_reply (c, IRC_ERR_USERSDISABLED); +} + +static void +irc_handle_away (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + { + cstr_set (&c->away_message, NULL); + irc_send_reply (c, IRC_RPL_UNAWAY); + } + else + { + cstr_set (&c->away_message, xstrdup (msg->params.vector[0])); + irc_send_reply (c, IRC_RPL_NOWAWAY); + } +} + +static void +irc_handle_ison (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + + struct str result = str_make (); + const char *nick; + if (str_map_find (&c->ctx->users, (nick = msg->params.vector[0]))) + str_append (&result, nick); + for (size_t i = 1; i < msg->params.len; i++) + if (str_map_find (&c->ctx->users, (nick = msg->params.vector[i]))) + str_append_printf (&result, " %s", nick); + + irc_send_reply (c, IRC_RPL_ISON, result.str); + str_free (&result); +} + +static void +irc_handle_admin (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len > 0 && !irc_is_this_me (c->ctx, msg->params.vector[0])) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[0]); + irc_send_reply (c, IRC_ERR_NOADMININFO, c->ctx->server_name); +} + +static void +irc_handle_stats_links (struct client *c, const struct irc_message *msg) +{ + // There is only an "l" query in RFC 2812 but we cannot link, + // so instead we provide the "L" query giving information for all users + const char *filter = NULL; + if (msg->params.len > 1) + filter = msg->params.vector[1]; + + for (struct client *iter = c->ctx->clients; iter; iter = iter->next) + { + if (filter && irc_strcmp (iter->nickname, filter)) + continue; + irc_send_reply (c, IRC_RPL_STATSLINKINFO, + iter->address, // linkname + iter->write_buffer.len, // sendq + iter->n_sent_messages, iter->sent_bytes / 1024, + iter->n_received_messages, iter->received_bytes / 1024, + (long long) (time (NULL) - iter->opened)); + } +} + +static void +irc_handle_stats_commands (struct client *c) +{ + struct str_map_iter iter = str_map_iter_make (&c->ctx->handlers); + struct irc_command *handler; + while ((handler = str_map_iter_next (&iter))) + { + if (!handler->n_received) + continue; + irc_send_reply (c, IRC_RPL_STATSCOMMANDS, handler->name, + handler->n_received, handler->bytes_received, (size_t) 0); + } +} + +static void +irc_handle_stats_uptime (struct client *c) +{ + time_t uptime = time (NULL) - c->ctx->started; + + int days = uptime / 60 / 60 / 24; + int hours = (uptime % (60 * 60 * 24)) / 60 / 60; + int mins = (uptime % (60 * 60)) / 60; + int secs = uptime % 60; + + irc_send_reply (c, IRC_RPL_STATSUPTIME, days, hours, mins, secs); +} + +static void +irc_handle_stats (const struct irc_message *msg, struct client *c) +{ + char query = 0; + if (msg->params.len > 0) + query = *msg->params.vector[0]; + if (msg->params.len > 1 && !irc_is_this_me (c->ctx, msg->params.vector[1])) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[1]); + if (!(c->mode & IRC_USER_MODE_OPERATOR)) + RETURN_WITH_REPLY (c, IRC_ERR_NOPRIVILEGES); + + switch (query) + { + case 'L': irc_handle_stats_links (c, msg); break; + case 'm': irc_handle_stats_commands (c); break; + case 'u': irc_handle_stats_uptime (c); break; + } + + irc_send_reply (c, IRC_RPL_ENDOFSTATS, query ? query : '*'); +} + +static void +irc_handle_links (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len > 1 && !irc_is_this_me (c->ctx, msg->params.vector[0])) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHSERVER, msg->params.vector[0]); + + const char *mask = "*"; + if (msg->params.len > 0) + mask = msg->params.vector[msg->params.len > 1]; + + if (!irc_fnmatch (mask, c->ctx->server_name)) + irc_send_reply (c, IRC_RPL_LINKS, mask, + c->ctx->server_name, 0 /* hop count */, + str_map_find (&c->ctx->config, "server_info")); + irc_send_reply (c, IRC_RPL_ENDOFLINKS, mask); +} + +static void +irc_handle_wallops (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 1) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + if (!(c->mode & IRC_USER_MODE_OPERATOR)) + RETURN_WITH_REPLY (c, IRC_ERR_NOPRIVILEGES); + + const char *message = msg->params.vector[0]; + + // Our interpretation: anonymize the sender, + // and target all users who want to receive these messages + struct str_map_iter iter = str_map_iter_make (&c->ctx->users); + struct client *target; + while ((target = str_map_iter_next (&iter))) + { + if (target != c && !(target->mode & IRC_USER_MODE_RX_WALLOPS)) + continue; + + client_send (target, ":%s WALLOPS :%s", c->ctx->server_name, message); + } +} + +static void +irc_handle_kill (const struct irc_message *msg, struct client *c) +{ + if (msg->params.len < 2) + RETURN_WITH_REPLY (c, IRC_ERR_NEEDMOREPARAMS, msg->command); + if (!(c->mode & IRC_USER_MODE_OPERATOR)) + RETURN_WITH_REPLY (c, IRC_ERR_NOPRIVILEGES); + + struct client *target; + if (!(target = str_map_find (&c->ctx->users, msg->params.vector[0]))) + RETURN_WITH_REPLY (c, IRC_ERR_NOSUCHNICK, msg->params.vector[0]); + + client_send (target, ":%s!%s@%s KILL %s :%s", + c->nickname, c->username, c->hostname, + target->nickname, msg->params.vector[1]); + + char *reason = xstrdup_printf ("Killed by %s: %s", + c->nickname, msg->params.vector[1]); + client_close_link (target, reason); + free (reason); +} + +static void +irc_handle_die (const struct irc_message *msg, struct client *c) +{ + (void) msg; + + if (!(c->mode & IRC_USER_MODE_OPERATOR)) + RETURN_WITH_REPLY (c, IRC_ERR_NOPRIVILEGES); + if (!c->ctx->quitting) + irc_initiate_quit (c->ctx); +} + +// ----------------------------------------------------------------------------- + +static void +irc_register_handlers (struct server_context *ctx) +{ + // TODO: add an index for IRC_ERR_NOSUCHSERVER validation? + // TODO: add a minimal parameter count? + // TODO: add a field for oper-only commands? + static struct irc_command message_handlers[] = + { + { "CAP", false, irc_handle_cap, 0, 0 }, + { "PASS", false, irc_handle_pass, 0, 0 }, + { "NICK", false, irc_handle_nick, 0, 0 }, + { "USER", false, irc_handle_user, 0, 0 }, + + { "USERHOST", true, irc_handle_userhost, 0, 0 }, + { "LUSERS", true, irc_handle_lusers, 0, 0 }, + { "MOTD", true, irc_handle_motd, 0, 0 }, + { "PING", true, irc_handle_ping, 0, 0 }, + { "PONG", false, irc_handle_pong, 0, 0 }, + { "QUIT", false, irc_handle_quit, 0, 0 }, + { "TIME", true, irc_handle_time, 0, 0 }, + { "VERSION", true, irc_handle_version, 0, 0 }, + { "USERS", true, irc_handle_users, 0, 0 }, + { "SUMMON", true, irc_handle_summon, 0, 0 }, + { "AWAY", true, irc_handle_away, 0, 0 }, + { "ADMIN", true, irc_handle_admin, 0, 0 }, + { "STATS", true, irc_handle_stats, 0, 0 }, + { "LINKS", true, irc_handle_links, 0, 0 }, + { "WALLOPS", true, irc_handle_wallops, 0, 0 }, + + { "MODE", true, irc_handle_mode, 0, 0 }, + { "PRIVMSG", true, irc_handle_privmsg, 0, 0 }, + { "NOTICE", true, irc_handle_notice, 0, 0 }, + { "JOIN", true, irc_handle_join, 0, 0 }, + { "PART", true, irc_handle_part, 0, 0 }, + { "KICK", true, irc_handle_kick, 0, 0 }, + { "INVITE", true, irc_handle_invite, 0, 0 }, + { "TOPIC", true, irc_handle_topic, 0, 0 }, + { "LIST", true, irc_handle_list, 0, 0 }, + { "NAMES", true, irc_handle_names, 0, 0 }, + { "WHO", true, irc_handle_who, 0, 0 }, + { "WHOIS", true, irc_handle_whois, 0, 0 }, + { "WHOWAS", true, irc_handle_whowas, 0, 0 }, + { "ISON", true, irc_handle_ison, 0, 0 }, + + { "KILL", true, irc_handle_kill, 0, 0 }, + { "DIE", true, irc_handle_die, 0, 0 }, + }; + + for (size_t i = 0; i < N_ELEMENTS (message_handlers); i++) + { + const struct irc_command *cmd = &message_handlers[i]; + str_map_set (&ctx->handlers, cmd->name, (void *) cmd); + } +} + +static void +irc_process_message (const struct irc_message *msg, + const char *raw, void *user_data) +{ + struct client *c = user_data; + if (c->closing_link) + return; + + c->n_received_messages++; + c->received_bytes += strlen (raw) + 2; + + if (!flood_detector_check (&c->antiflood)) + { + client_close_link (c, "Excess flood"); + return; + } + + struct irc_command *cmd = str_map_find (&c->ctx->handlers, msg->command); + if (!cmd) + irc_send_reply (c, IRC_ERR_UNKNOWNCOMMAND, msg->command); + else + { + cmd->n_received++; + cmd->bytes_received += strlen (raw) + 2; + + if (cmd->requires_registration && !c->registered) + irc_send_reply (c, IRC_ERR_NOTREGISTERED); + else + cmd->handler (msg, c); + } +} + +// --- Network I/O ------------------------------------------------------------- + +static bool +irc_try_read (struct client *c) +{ + struct str *buf = &c->read_buffer; + ssize_t n_read; + + while (true) + { + str_reserve (buf, 512); + n_read = read (c->socket_fd, buf->str + buf->len, + buf->alloc - buf->len - 1 /* null byte */); + + if (n_read > 0) + { + buf->str[buf->len += n_read] = '\0'; + // TODO: discard characters above the 512 character limit + // FIXME: we should probably discard the data if closing_link + irc_process_buffer (buf, irc_process_message, c); + continue; + } + if (n_read == 0) + { + client_kill (c, NULL); + return false; + } + + if (errno == EAGAIN) + return true; + if (errno == EINTR) + continue; + + print_debug ("%s: %s: %s", __func__, "read", strerror (errno)); + client_kill (c, strerror (errno)); + return false; + } +} + +static bool +irc_try_read_tls (struct client *c) +{ + if (c->ssl_tx_want_rx) + return true; + + struct str *buf = &c->read_buffer; + c->ssl_rx_want_tx = false; + while (true) + { + str_reserve (buf, 512); + ERR_clear_error (); + int n_read = SSL_read (c->ssl, buf->str + buf->len, + buf->alloc - buf->len - 1 /* null byte */); + + const char *error_info = NULL; + switch (xssl_get_error (c->ssl, n_read, &error_info)) + { + case SSL_ERROR_NONE: + buf->str[buf->len += n_read] = '\0'; + // TODO: discard characters above the 512 character limit + // FIXME: we should probably discard the data if closing_link + irc_process_buffer (buf, irc_process_message, c); + continue; + case SSL_ERROR_ZERO_RETURN: + client_kill (c, NULL); + return false; + case SSL_ERROR_WANT_READ: + return true; + case SSL_ERROR_WANT_WRITE: + c->ssl_rx_want_tx = true; + return true; + case XSSL_ERROR_TRY_AGAIN: + continue; + default: + print_debug ("%s: %s: %s", __func__, "SSL_read", error_info); + client_kill (c, error_info); + return false; + } + } +} + +static bool +irc_try_write (struct client *c) +{ + struct str *buf = &c->write_buffer; + ssize_t n_written; + + while (buf->len) + { + n_written = write (c->socket_fd, buf->str, buf->len); + if (n_written >= 0) + { + str_remove_slice (buf, 0, n_written); + continue; + } + + if (errno == EAGAIN) + return true; + if (errno == EINTR) + continue; + + print_debug ("%s: %s: %s", __func__, "write", strerror (errno)); + client_kill (c, strerror (errno)); + return false; + } + return true; +} + +static bool +irc_try_write_tls (struct client *c) +{ + if (c->ssl_rx_want_tx) + return true; + + struct str *buf = &c->write_buffer; + c->ssl_tx_want_rx = false; + while (buf->len) + { + ERR_clear_error (); + int n_written = SSL_write (c->ssl, buf->str, buf->len); + + const char *error_info = NULL; + switch (xssl_get_error (c->ssl, n_written, &error_info)) + { + case SSL_ERROR_NONE: + str_remove_slice (buf, 0, n_written); + continue; + case SSL_ERROR_ZERO_RETURN: + client_kill (c, NULL); + return false; + case SSL_ERROR_WANT_WRITE: + return true; + case SSL_ERROR_WANT_READ: + c->ssl_tx_want_rx = true; + return true; + case XSSL_ERROR_TRY_AGAIN: + continue; + default: + print_debug ("%s: %s: %s", __func__, "SSL_write", error_info); + client_kill (c, error_info); + return false; + } + } + return true; +} + +// ----------------------------------------------------------------------------- + +static bool +irc_autodetect_tls (struct client *c) +{ + // Trivial SSL/TLS autodetection. The first block of data returned by + // recv() must be at least three bytes long for this to work reliably, + // but that should not pose a problem in practice. + // + // SSL2: 1xxx xxxx | xxxx xxxx | <1> + // (message length) (client hello) + // SSL3/TLS: <22> | <3> | xxxx xxxx + // (handshake)| (protocol version) + // + // Such byte sequences should never occur at the beginning of regular IRC + // communication, which usually begins with USER/NICK/PASS/SERVICE. + + char buf[3]; +start: + switch (recv (c->socket_fd, buf, sizeof buf, MSG_PEEK)) + { + case 3: + if ((buf[0] & 0x80) && buf[2] == 1) + return true; + // Fall-through + case 2: + if (buf[0] == 22 && buf[1] == 3) + return true; + break; + case 1: + if (buf[0] == 22) + return true; + break; + case 0: + break; + default: + if (errno == EINTR) + goto start; + } + return false; +} + +static bool +client_initialize_tls (struct client *c) +{ + const char *error_info = NULL; + if (!c->ctx->ssl_ctx) + { + error_info = "TLS support disabled"; + goto error_ssl_1; + } + + ERR_clear_error (); + + c->ssl = SSL_new (c->ctx->ssl_ctx); + if (!c->ssl) + goto error_ssl_2; + if (!SSL_set_fd (c->ssl, c->socket_fd)) + goto error_ssl_3; + + SSL_set_accept_state (c->ssl); + return true; + +error_ssl_3: + SSL_free (c->ssl); + c->ssl = NULL; +error_ssl_2: + error_info = xerr_describe_error (); +error_ssl_1: + print_debug ("could not initialize TLS for %s: %s", c->address, error_info); + return false; +} + +// ----------------------------------------------------------------------------- + +static void +on_client_ready (const struct pollfd *pfd, void *user_data) +{ + struct client *c = user_data; + if (!c->initialized) + { + hard_assert (pfd->events == POLLIN); + if (irc_autodetect_tls (c) && !client_initialize_tls (c)) + { + client_kill (c, NULL); + return; + } + c->initialized = true; + client_set_ping_timer (c); + } + + if (c->ssl) + { + // Reads may want to write, writes may want to read, poll() may + // return unexpected things in `revents'... let's try both + if (!irc_try_read_tls (c) || !irc_try_write_tls (c)) + return; + } + else if (!irc_try_read (c) || !irc_try_write (c)) + return; + + client_update_poller (c, pfd); + + // The purpose of the `closing_link' state is to transfer the `ERROR' + if (c->closing_link && !c->half_closed && !c->write_buffer.len) + { + // To make sure the client has received our ERROR message, we must + // first half-close the connection, otherwise it could happen that they + // receive a RST from our TCP stack first when we receive further data + + // We only send the "close notify" alert if libssl can write to the + // socket at this moment. All the other data has been already written, + // though, and the client will receive a TCP half-close as usual, so + // it's not that important if the alert actually gets through. + if (c->ssl) + (void) SSL_shutdown (c->ssl); + + // Either the shutdown succeeds, in which case we set a flag so that + // we don't retry this action and wait until we get an EOF, or it fails + // and we just kill the client straight away + if (!shutdown (c->socket_fd, SHUT_WR)) + c->half_closed = true; + else + client_kill (c, NULL); + } +} + +static void +client_update_poller (struct client *c, const struct pollfd *pfd) +{ + // We must not poll for writing when the connection hasn't been initialized + int new_events = POLLIN; + if (c->ssl) + { + if (c->write_buffer.len || c->ssl_rx_want_tx) + new_events |= POLLOUT; + + // While we're waiting for an opposite event, we ignore the original + if (c->ssl_rx_want_tx) new_events &= ~POLLIN; + if (c->ssl_tx_want_rx) new_events &= ~POLLOUT; + } + else if (c->initialized && c->write_buffer.len) + new_events |= POLLOUT; + + hard_assert (new_events != 0); + if (!pfd || pfd->events != new_events) + poller_fd_set (&c->socket_event, new_events); +} + +static void +client_finish_connection (struct client *c) +{ + c->gni = NULL; + + c->address = format_host_port_pair (c->hostname, c->port); + print_debug ("accepted connection from %s", c->address); + + client_update_poller (c, NULL); + client_set_kill_timer (c); +} + +static void +on_client_gni_resolved (int result, char *host, char *port, void *user_data) +{ + struct client *c = user_data; + + if (result) + print_debug ("%s: %s", "getnameinfo", gai_strerror (result)); + else + { + cstr_set (&c->hostname, xstrdup (host)); + (void) port; + } + + poller_timer_reset (&c->gni_timer); + client_finish_connection (c); +} + +static void +on_client_gni_timer (struct client *c) +{ + async_cancel (&c->gni->async); + client_finish_connection (c); +} + +static bool +irc_try_fetch_client (struct server_context *ctx, int listen_fd) +{ + // XXX: `struct sockaddr_storage' is not the most portable thing + struct sockaddr_storage peer; + socklen_t peer_len = sizeof peer; + + int fd = accept (listen_fd, (struct sockaddr *) &peer, &peer_len); + if (fd == -1) + { + if (errno == EAGAIN || errno == EWOULDBLOCK) + return false; + if (errno == EINTR) + return true; + + if (accept_error_is_transient (errno)) + print_warning ("%s: %s", "accept", strerror (errno)); + else + print_fatal ("%s: %s", "accept", strerror (errno)); + return true; + } + + hard_assert (peer_len <= sizeof peer); + set_blocking (fd, false); + + // A little bit questionable once the traffic gets high enough (IMO), + // but it reduces silly latencies that we don't need because we already + // do buffer our output + int yes = 1; + soft_assert (setsockopt (fd, IPPROTO_TCP, TCP_NODELAY, + &yes, sizeof yes) != -1); + + if (ctx->max_connections != 0 && ctx->n_clients >= ctx->max_connections) + { + print_debug ("connection limit reached, refusing connection"); + close (fd); + return true; + } + + char host[NI_MAXHOST] = "unknown", port[NI_MAXSERV] = "unknown"; + int err = getnameinfo ((struct sockaddr *) &peer, peer_len, + host, sizeof host, port, sizeof port, NI_NUMERICHOST | NI_NUMERICSERV); + if (err) + print_debug ("%s: %s", "getnameinfo", gai_strerror (err)); + + struct client *c = client_new (); + c->ctx = ctx; + c->opened = time (NULL); + c->socket_fd = fd; + c->hostname = xstrdup (host); + c->port = xstrdup (port); + c->last_active = time (NULL); + LIST_PREPEND (ctx->clients, c); + ctx->n_clients++; + + c->socket_event = poller_fd_make (&c->ctx->poller, c->socket_fd); + c->socket_event.dispatcher = (poller_fd_fn) on_client_ready; + c->socket_event.user_data = c; + + c->kill_timer = poller_timer_make (&c->ctx->poller); + c->kill_timer.dispatcher = (poller_timer_fn) on_client_kill_timer; + c->kill_timer.user_data = c; + + c->timeout_timer = poller_timer_make (&c->ctx->poller); + c->timeout_timer.dispatcher = (poller_timer_fn) on_client_timeout_timer; + c->timeout_timer.user_data = c; + + c->ping_timer = poller_timer_make (&c->ctx->poller); + c->ping_timer.dispatcher = (poller_timer_fn) on_client_ping_timer; + c->ping_timer.user_data = c; + + // Resolve the client's hostname first; this is a blocking operation that + // depends on the network, so run it asynchronously with some timeout + c->gni = async_getnameinfo (&ctx->poller.common.async, + (const struct sockaddr *) &peer, peer_len, NI_NUMERICSERV); + c->gni->dispatcher = on_client_gni_resolved; + c->gni->user_data = c; + + c->gni_timer = poller_timer_make (&c->ctx->poller); + c->gni_timer.dispatcher = (poller_timer_fn) on_client_gni_timer; + c->gni_timer.user_data = c; + + poller_timer_set (&c->gni_timer, 5000); + return true; +} + +static void +on_irc_client_available (const struct pollfd *pfd, void *user_data) +{ + struct server_context *ctx = user_data; + while (irc_try_fetch_client (ctx, pfd->fd)) + ; +} + +// --- Application setup ------------------------------------------------------- + +static int +irc_ssl_verify_callback (int verify_ok, X509_STORE_CTX *ctx) +{ + (void) verify_ok; + (void) ctx; + + // RFC 5246: "If the client has sent a certificate with signing ability, + // a digitally-signed CertificateVerify message is sent to explicitly + // verify possession of the private key in the certificate." + // + // The handshake will fail if the client doesn't have a matching private + // key, see OpenSSL's tls_process_cert_verify(), and the CertificateVerify + // message cannot be skipped (except for a case where it doesn't matter). + // Thus we're fine checking just the cryptographic hash of the certificate. + + // We only want to provide additional privileges based on the client's + // certificate, so let's not terminate the connection because of a failure + // (especially since self-signed certificates are likely to be used). + return 1; +} + +static void +irc_ssl_info_callback (const SSL *ssl, int where, int ret) +{ + // For debugging only; provides us with the most important information + + struct str s = str_make (); + if (where & SSL_CB_LOOP) + str_append_printf (&s, "loop (%s) ", + SSL_state_string_long (ssl)); + if (where & SSL_CB_EXIT) + str_append_printf (&s, "exit (%d in %s) ", ret, + SSL_state_string_long (ssl)); + + if (where & SSL_CB_READ) str_append (&s, "read "); + if (where & SSL_CB_WRITE) str_append (&s, "write "); + + if (where & SSL_CB_ALERT) + str_append_printf (&s, "alert (%s: %s) ", + SSL_alert_type_string_long (ret), + SSL_alert_desc_string_long (ret)); + + if (where & SSL_CB_HANDSHAKE_START) str_append (&s, "handshake start "); + if (where & SSL_CB_HANDSHAKE_DONE) str_append (&s, "handshake done "); + + print_debug ("ssl <%p> %s", ssl, s.str); + str_free (&s); +} + +static bool +irc_initialize_ssl_ctx (struct server_context *ctx, + const char *cert_path, const char *key_path, struct error **e) +{ + ERR_clear_error (); + + ctx->ssl_ctx = SSL_CTX_new (SSLv23_server_method ()); + if (!ctx->ssl_ctx) + { + error_set (e, "%s: %s", "could not initialize TLS", + xerr_describe_error ()); + return false; + } + SSL_CTX_set_verify (ctx->ssl_ctx, + SSL_VERIFY_PEER | SSL_VERIFY_CLIENT_ONCE, irc_ssl_verify_callback); + + if (g_debug_mode) + SSL_CTX_set_info_callback (ctx->ssl_ctx, irc_ssl_info_callback); + + const unsigned char session_id_context[SSL_MAX_SSL_SESSION_ID_LENGTH] + = PROGRAM_NAME; + (void) SSL_CTX_set_session_id_context (ctx->ssl_ctx, + session_id_context, sizeof session_id_context); + + // IRC is not particularly reconnect-heavy, prefer forward secrecy + SSL_CTX_set_session_cache_mode (ctx->ssl_ctx, SSL_SESS_CACHE_OFF); + + // Gah, spare me your awkward semantics, I just want to push data! + SSL_CTX_set_mode (ctx->ssl_ctx, + SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER | SSL_MODE_ENABLE_PARTIAL_WRITE); + + // Disable deprecated protocols (see RFC 7568) + SSL_CTX_set_options (ctx->ssl_ctx, SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3); + + // XXX: perhaps we should read the files ourselves for better messages + const char *ciphers = str_map_find (&ctx->config, "tls_ciphers"); + if (!SSL_CTX_set_cipher_list (ctx->ssl_ctx, ciphers)) + error_set (e, "failed to select any cipher from the cipher list"); + else if (!SSL_CTX_use_certificate_chain_file (ctx->ssl_ctx, cert_path)) + error_set (e, "%s: %s", "setting the TLS certificate failed", + xerr_describe_error ()); + else if (!SSL_CTX_use_PrivateKey_file + (ctx->ssl_ctx, key_path, SSL_FILETYPE_PEM)) + error_set (e, "%s: %s", "setting the TLS private key failed", + xerr_describe_error ()); + else + // TODO: SSL_CTX_check_private_key()? It has probably already been + // checked by SSL_CTX_use_PrivateKey_file() above. + return true; + + SSL_CTX_free (ctx->ssl_ctx); + ctx->ssl_ctx = NULL; + return false; +} + +static bool +irc_initialize_tls (struct server_context *ctx, struct error **e) +{ + const char *tls_cert = str_map_find (&ctx->config, "tls_cert"); + const char *tls_key = str_map_find (&ctx->config, "tls_key"); + + // Only try to enable SSL support if the user configures it; it is not + // a failure if no one has requested it. + if (!tls_cert && !tls_key) + return true; + + if (!tls_cert) + error_set (e, "no TLS certificate set"); + else if (!tls_key) + error_set (e, "no TLS private key set"); + if (!tls_cert || !tls_key) + return false; + + bool result = false; + + char *cert_path = resolve_filename + (tls_cert, resolve_relative_config_filename); + char *key_path = resolve_filename + (tls_key, resolve_relative_config_filename); + if (!cert_path) + error_set (e, "%s: %s", "cannot open file", tls_cert); + else if (!key_path) + error_set (e, "%s: %s", "cannot open file", tls_key); + else + result = irc_initialize_ssl_ctx (ctx, cert_path, key_path, e); + + free (cert_path); + free (key_path); + return result; +} + +static bool +irc_initialize_catalog (struct server_context *ctx, struct error **e) +{ + hard_assert (ctx->catalog == (nl_catd) -1); + const char *catalog = str_map_find (&ctx->config, "catalog"); + if (!catalog) + return true; + + char *path = resolve_filename (catalog, resolve_relative_config_filename); + if (!path) + { + error_set (e, "%s: %s", "cannot open file", catalog); + return false; + } + ctx->catalog = catopen (path, NL_CAT_LOCALE); + free (path); + + if (ctx->catalog == (nl_catd) -1) + { + error_set (e, "%s: %s", + "failed reading the message catalog file", strerror (errno)); + return false; + } + return true; +} + +static bool +irc_initialize_motd (struct server_context *ctx, struct error **e) +{ + hard_assert (ctx->motd.len == 0); + const char *motd = str_map_find (&ctx->config, "motd"); + if (!motd) + return true; + + char *path = resolve_filename (motd, resolve_relative_config_filename); + if (!path) + { + error_set (e, "%s: %s", "cannot open file", motd); + return false; + } + FILE *fp = fopen (path, "r"); + free (path); + + if (!fp) + { + error_set (e, "%s: %s", + "failed reading the MOTD file", strerror (errno)); + return false; + } + + struct str line = str_make (); + while (read_line (fp, &line)) + strv_append_owned (&ctx->motd, str_steal (&line)); + str_free (&line); + + fclose (fp); + return true; +} + +static bool +irc_parse_config_unsigned (const char *name, const char *value, unsigned *out, + unsigned long min, unsigned long max, struct error **e) +{ + unsigned long ul; + hard_assert (value != NULL); + if (!xstrtoul (&ul, value, 10) || ul > max || ul < min) + { + error_set (e, "invalid configuration value for `%s': %s", + name, "the number is invalid or out of range"); + return false; + } + *out = ul; + return true; +} + +/// This function handles values that require validation before their first use, +/// or some kind of a transformation (such as conversion to an integer) needs +/// to be done before they can be used directly. +static bool +irc_parse_config (struct server_context *ctx, struct error **e) +{ +#define PARSE_UNSIGNED(name, min, max) \ + irc_parse_config_unsigned (#name, str_map_find (&ctx->config, #name), \ + &ctx->name, min, max, e) + + if (!PARSE_UNSIGNED (ping_interval, 1, UINT_MAX) + || !PARSE_UNSIGNED (max_connections, 0, UINT_MAX)) + return false; + + bool result = true; + struct strv fingerprints = strv_make (); + const char *operators = str_map_find (&ctx->config, "operators"); + if (operators) + cstr_split (operators, ",", true, &fingerprints); + for (size_t i = 0; i < fingerprints.len; i++) + { + const char *key = fingerprints.vector[i]; + if (!irc_is_valid_fingerprint (key)) + { + error_set (e, "invalid configuration value for `%s': %s", + "operators", "invalid fingerprint value"); + result = false; + break; + } + str_map_set (&ctx->operators, key, (void *) 1); + } + strv_free (&fingerprints); + return result; +} + +static bool +irc_initialize_server_name (struct server_context *ctx, struct error **e) +{ + enum validation_result res; + const char *server_name = str_map_find (&ctx->config, "server_name"); + if (server_name) + { + res = irc_validate_hostname (server_name); + if (res != VALIDATION_OK) + { + error_set (e, "invalid configuration value for `%s': %s", + "server_name", irc_validate_to_str (res)); + return false; + } + ctx->server_name = xstrdup (server_name); + } + else + { + long host_name_max = sysconf (_SC_HOST_NAME_MAX); + if (host_name_max <= 0) + host_name_max = _POSIX_HOST_NAME_MAX; + + char hostname[host_name_max + 1]; + if (gethostname (hostname, sizeof hostname)) + { + error_set (e, "%s: %s", + "getting the hostname failed", strerror (errno)); + return false; + } + res = irc_validate_hostname (hostname); + if (res != VALIDATION_OK) + { + error_set (e, + "`%s' is not set and the hostname (`%s') cannot be used: %s", + "server_name", hostname, irc_validate_to_str (res)); + return false; + } + ctx->server_name = xstrdup (hostname); + } + return true; +} + +static bool +irc_lock_pid_file (struct server_context *ctx, struct error **e) +{ + const char *path = str_map_find (&ctx->config, "pid_file"); + if (!path) + return true; + + char *resolved = resolve_filename (path, resolve_relative_runtime_filename); + bool result = lock_pid_file (resolved, e) != -1; + free (resolved); + return result; +} + +static int +irc_listen (struct addrinfo *ai) +{ + int fd = socket (ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd == -1) + return -1; + set_cloexec (fd); + + int yes = 1; + soft_assert (setsockopt (fd, SOL_SOCKET, SO_KEEPALIVE, + &yes, sizeof yes) != -1); + soft_assert (setsockopt (fd, SOL_SOCKET, SO_REUSEADDR, + &yes, sizeof yes) != -1); + +#if defined SOL_IPV6 && defined IPV6_V6ONLY + // Make NULL always bind to both IPv4 and IPv6, irrespectively of the order + // of results; only INADDR6_ANY seems to be affected by this + if (ai->ai_family == AF_INET6) + soft_assert (setsockopt (fd, SOL_IPV6, IPV6_V6ONLY, + &yes, sizeof yes) != -1); +#endif + + char *address = gai_reconstruct_address (ai); + if (bind (fd, ai->ai_addr, ai->ai_addrlen)) + print_error ("bind to %s failed: %s", address, strerror (errno)); + else if (listen (fd, 16 /* arbitrary number */)) + print_error ("listen on %s failed: %s", address, strerror (errno)); + else + { + print_status ("listening on %s", address); + free (address); + return fd; + } + + free (address); + xclose (fd); + return -1; +} + +static void +irc_listen_resolve (struct server_context *ctx, + const char *host, const char *port, struct addrinfo *gai_hints) +{ + struct addrinfo *gai_result = NULL, *gai_iter = NULL; + int err = getaddrinfo (host, port, gai_hints, &gai_result); + if (err) + { + char *address = format_host_port_pair (host, port); + print_error ("binding to %s failed: %s: %s", + address, "getaddrinfo", gai_strerror (err)); + free (address); + return; + } + + int fd; + for (gai_iter = gai_result; gai_iter; gai_iter = gai_iter->ai_next) + { + if (ctx->listen_len == ctx->listen_alloc) + break; + + if ((fd = irc_listen (gai_iter)) == -1) + continue; + set_blocking (fd, false); + + struct poller_fd *event = &ctx->listen_events[ctx->listen_len]; + *event = poller_fd_make (&ctx->poller, fd); + event->dispatcher = (poller_fd_fn) on_irc_client_available; + event->user_data = ctx; + + ctx->listen_fds[ctx->listen_len++] = fd; + poller_fd_set (event, POLLIN); + } + freeaddrinfo (gai_result); +} + +static bool +irc_setup_listen_fds (struct server_context *ctx, struct error **e) +{ + const char *bind_host = str_map_find (&ctx->config, "bind_host"); + const char *bind_port = str_map_find (&ctx->config, "bind_port"); + hard_assert (bind_port != NULL); // We have a default value for this + + struct addrinfo gai_hints; + memset (&gai_hints, 0, sizeof gai_hints); + + gai_hints.ai_socktype = SOCK_STREAM; + gai_hints.ai_flags = AI_PASSIVE; + + struct strv ports = strv_make (); + cstr_split (bind_port, ",", true, &ports); + + // For C and simplicity's sake let's assume that the host will resolve + // to at most two different addresses: IPv4 and IPv6 in case it is NULL + ctx->listen_alloc = ports.len * 2; + + ctx->listen_fds = + xcalloc (ctx->listen_alloc, sizeof *ctx->listen_fds); + ctx->listen_events = + xcalloc (ctx->listen_alloc, sizeof *ctx->listen_events); + for (size_t i = 0; i < ports.len; i++) + irc_listen_resolve (ctx, bind_host, ports.vector[i], &gai_hints); + strv_free (&ports); + + if (!ctx->listen_len) + { + error_set (e, "%s: %s", + "network setup failed", "no ports to listen on"); + return false; + } + return true; +} + +// --- Main -------------------------------------------------------------------- + +static void +on_signal_pipe_readable (const struct pollfd *fd, struct server_context *ctx) +{ + char dummy; + (void) read (fd->fd, &dummy, 1); + + if (g_termination_requested && !ctx->quitting) + irc_initiate_quit (ctx); +} + +static void +daemonize (struct server_context *ctx) +{ + print_status ("daemonizing..."); + + if (chdir ("/")) + exit_fatal ("%s: %s", "chdir", strerror (errno)); + + // Because of systemd, we need to exit the parent process _after_ writing + // a PID file, otherwise our grandchild would receive a SIGTERM + int sync_pipe[2]; + if (pipe (sync_pipe)) + exit_fatal ("%s: %s", "pipe", strerror (errno)); + + pid_t pid; + if ((pid = fork ()) < 0) + exit_fatal ("%s: %s", "fork", strerror (errno)); + else if (pid) + { + // Wait until all write ends of the pipe are closed, which can mean + // either success or failure, we don't need to care + xclose (sync_pipe[PIPE_WRITE]); + + char dummy; + if (read (sync_pipe[PIPE_READ], &dummy, 1) < 0) + exit_fatal ("%s: %s", "read", strerror (errno)); + + exit (EXIT_SUCCESS); + } + + setsid (); + signal (SIGHUP, SIG_IGN); + + if ((pid = fork ()) < 0) + exit_fatal ("%s: %s", "fork", strerror (errno)); + else if (pid) + exit (EXIT_SUCCESS); + + openlog (PROGRAM_NAME, LOG_NDELAY | LOG_NOWAIT | LOG_PID, 0); + g_log_message_real = log_message_syslog; + + // Write the PID file (if so configured) and get rid of the pipe, so that + // the read() in our grandparent finally returns zero (no write ends) + struct error *e = NULL; + if (!irc_lock_pid_file (ctx, &e)) + exit_fatal ("%s", e->message); + + xclose (sync_pipe[PIPE_READ]); + xclose (sync_pipe[PIPE_WRITE]); + + // XXX: we may close our own descriptors this way, crippling ourselves; + // there is no real guarantee that we will start with all three + // descriptors open. In theory we could try to enumerate the descriptors + // at the start of main(). + for (int i = 0; i < 3; i++) + xclose (i); + + int tty = open ("/dev/null", O_RDWR); + if (tty != 0 || dup (0) != 1 || dup (0) != 2) + exit_fatal ("failed to reopen FD's: %s", strerror (errno)); + + poller_post_fork (&ctx->poller); +} + +static void +setup_limits (void) +{ + struct rlimit limit; + if (getrlimit (RLIMIT_NOFILE, &limit)) + { + print_warning ("%s: %s", "getrlimit", strerror (errno)); + return; + } + + limit.rlim_cur = limit.rlim_max; + if (setrlimit (RLIMIT_NOFILE, &limit)) + print_warning ("%s: %s", "setrlimit", strerror (errno)); +} + +int +main (int argc, char *argv[]) +{ + // Need to call this first as all string maps depend on it + siphash_wrapper_randomize (); + + static const struct opt opts[] = + { + { 'd', "debug", NULL, 0, "run in debug mode (do not daemonize)" }, + { 'h', "help", NULL, 0, "display this help and exit" }, + { 'V', "version", NULL, 0, "output version information and exit" }, + { 'w', "write-default-cfg", "FILENAME", + OPT_OPTIONAL_ARG | OPT_LONG_ONLY, + "write a default configuration file and exit" }, + { 0, NULL, NULL, 0, NULL } + }; + + struct opt_handler oh = + opt_handler_make (argc, argv, opts, NULL, "IRC daemon."); + + int c; + while ((c = opt_handler_get (&oh)) != -1) + switch (c) + { + case 'd': + g_debug_mode = true; + break; + case 'h': + opt_handler_usage (&oh, stdout); + exit (EXIT_SUCCESS); + case 'V': + printf (PROGRAM_NAME " " PROGRAM_VERSION "\n"); + exit (EXIT_SUCCESS); + case 'w': + call_simple_config_write_default (optarg, g_config_table); + exit (EXIT_SUCCESS); + default: + print_error ("wrong options"); + opt_handler_usage (&oh, stderr); + exit (EXIT_FAILURE); + } + + opt_handler_free (&oh); + + print_status (PROGRAM_NAME " " PROGRAM_VERSION " starting"); + setup_signal_handlers (); + setup_limits (); + init_openssl (); + + struct server_context ctx; + server_context_init (&ctx); + ctx.started = time (NULL); + irc_register_handlers (&ctx); + irc_register_cap_handlers (&ctx); + + struct error *e = NULL; + if (!simple_config_update_from_file (&ctx.config, &e)) + { + print_error ("error loading configuration: %s", e->message); + error_free (e); + exit (EXIT_FAILURE); + } + + ctx.signal_event = poller_fd_make (&ctx.poller, g_signal_pipe[0]); + ctx.signal_event.dispatcher = (poller_fd_fn) on_signal_pipe_readable; + ctx.signal_event.user_data = &ctx; + poller_fd_set (&ctx.signal_event, POLLIN); + + if (!irc_initialize_tls (&ctx, &e) + || !irc_initialize_server_name (&ctx, &e) + || !irc_initialize_motd (&ctx, &e) + || !irc_initialize_catalog (&ctx, &e) + || !irc_parse_config (&ctx, &e) + || !irc_setup_listen_fds (&ctx, &e)) + exit_fatal ("%s", e->message); + + if (!g_debug_mode) + daemonize (&ctx); + else if (!irc_lock_pid_file (&ctx, &e)) + exit_fatal ("%s", e->message); + +#if OpenBSD >= 201605 + // This won't be as simple once we decide to implement REHASH + if (pledge ("stdio inet dns", NULL)) + exit_fatal ("%s: %s", "pledge", strerror (errno)); +#endif + + ctx.polling = true; + while (ctx.polling) + poller_run (&ctx.poller); + + server_context_free (&ctx); + return EXIT_SUCCESS; +} @@ -0,0 +1,172 @@ +/* + * xF.c: a toothless IRC client frontend + * + * Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#include "config.h" +#define PROGRAM_NAME "xF" + +#include "common.c" +#include "xC-proto.c" + +#include <X11/Xatom.h> +#include <X11/Xlib.h> +#include <X11/keysym.h> +#include <X11/XKBlib.h> +#include <X11/Xft/Xft.h> + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static struct +{ + bool polling; + struct connector connector; + int socket; +} +g; + +static void +on_connector_connecting (void *user_data, const char *address) +{ + (void) user_data; + print_status ("connecting to %s...", address); +} + +static void +on_connector_error (void *user_data, const char *error) +{ + (void) user_data; + print_status ("connection failed: %s", error); +} + +static void +on_connector_failure (void *user_data) +{ + (void) user_data; + exit_fatal ("giving up"); +} + +static void +on_connector_connected (void *user_data, int socket, const char *hostname) +{ + (void) user_data; + (void) hostname; + g.polling = false; + g.socket = socket; +} + +static void +protocol_test (const char *host, const char *port) +{ + struct poller poller = {}; + poller_init (&poller); + + connector_init (&g.connector, &poller); + g.connector.on_connecting = on_connector_connecting; + g.connector.on_error = on_connector_error; + g.connector.on_connected = on_connector_connected; + g.connector.on_failure = on_connector_failure; + + connector_add_target (&g.connector, host, port); + + g.polling = true; + while (g.polling) + poller_run (&poller); + + connector_free (&g.connector); + + struct str s = str_make (); + str_pack_u32 (&s, 0); + struct relay_command_message m = {}; + m.data.hello.command = RELAY_COMMAND_HELLO; + m.data.hello.version = RELAY_VERSION; + if (!relay_command_message_serialize (&m, &s)) + exit_fatal ("serialization failed"); + + uint32_t len = htonl (s.len - sizeof len); + memcpy (s.str, &len, sizeof len); + if (errno = 0, write (g.socket, s.str, s.len) != (ssize_t) s.len) + exit_fatal ("short send or error: %s", strerror (errno)); + + char buf[1 << 20] = ""; + while (errno = 0, read (g.socket, &len, sizeof len) == sizeof len) + { + len = ntohl (len); + if (errno = 0, read (g.socket, buf, MIN (len, sizeof buf)) != len) + exit_fatal ("short read or error: %s", strerror (errno)); + + struct msg_unpacker r = msg_unpacker_make (buf, len); + struct relay_event_message m = {}; + if (!relay_event_message_deserialize (&m, &r)) + exit_fatal ("deserialization failed"); + if (msg_unpacker_get_available (&r)) + exit_fatal ("trailing data"); + + printf ("event: %d\n", m.data.event); + relay_event_message_free (&m); + } + exit_fatal ("short read or error: %s", strerror (errno)); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +int +main (int argc, char *argv[]) +{ + static const struct opt opts[] = + { + { 'h', "help", NULL, 0, "display this help and exit" }, + { 'V', "version", NULL, 0, "output version information and exit" }, + { 0, NULL, NULL, 0, NULL } + }; + + struct opt_handler oh = opt_handler_make (argc, argv, opts, + "HOST:PORT", "X11 frontend for xC."); + + int c; + while ((c = opt_handler_get (&oh)) != -1) + switch (c) + { + case 'h': + opt_handler_usage (&oh, stdout); + exit (EXIT_SUCCESS); + case 'V': + printf (PROGRAM_NAME " " PROGRAM_VERSION "\n"); + exit (EXIT_SUCCESS); + default: + print_error ("wrong options"); + opt_handler_usage (&oh, stderr); + exit (EXIT_FAILURE); + } + + argc -= optind; + argv += optind; + if (argc != 1) + { + opt_handler_usage (&oh, stderr); + exit (EXIT_FAILURE); + } + opt_handler_free (&oh); + + char *address = xstrdup (argv[0]); + const char *port = NULL, *host = tokenize_host_port (address, &port); + if (!port) + exit_fatal ("missing port number/service name"); + + // TODO: Actually implement an X11-based user interface. + protocol_test (host, port); + return 0; +} @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg version="1.1" width="48" height="48" viewBox="0 0 48 48" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + + <defs> + <linearGradient id="background" x1="0" y1="0" x2="1" y2="1"> + <stop stop-color="#808080" offset="0" /> + <stop stop-color="#000000" offset="1" /> + </linearGradient> + <!-- librsvg screws up the filter's orientation in a weird way + otherwise a larger blur value would look better --> + <filter id="shadow" color-interpolation-filters="sRGB"> + <feOffset dy="0.5" /> + <feGaussianBlur stdDeviation="0.5" /> + <feComposite in2="SourceGraphic" operator="in" /> + </filter> + <clipPath id="clip"> + <rect x="-7" y="-10" width="14" height="20" /> + </clipPath> + </defs> + + <circle cx="24" cy="24" r="20" + fill="url(#background)" stroke="#404040" stroke-width="2" /> + + <g transform="rotate(-45 24 24)" filter="url(#shadow)"> + <path d="m 12,25 h 24 v 11 h -5 v -8 h -4.5 v 6 h -5 v -6 h -9.5 z" + fill="#ffffff" /> + <g stroke-width="4" transform="translate(24, 16)" clip-path="url(#clip)" + stroke="#ffffff"> + <line x1="-8" x2="8" y1="-5" y2="5" /> + <line x1="-8" x2="8" y1="5" y2="-5" /> + </g> + </g> +</svg> Binary files differdiff --git a/xP/.gitignore b/xP/.gitignore new file mode 100644 index 0000000..ba4d8c3 --- /dev/null +++ b/xP/.gitignore @@ -0,0 +1,4 @@ +/xP +/proto.go +/public/proto.js +/public/mithril.js diff --git a/xP/Makefile b/xP/Makefile new file mode 100644 index 0000000..34de55a --- /dev/null +++ b/xP/Makefile @@ -0,0 +1,18 @@ +.POSIX: +.SUFFIXES: + +outputs = xP proto.go public/proto.js public/mithril.js +all: $(outputs) public/ircfmt.woff2 + +xP: xP.go proto.go + go build -o $@ +proto.go: ../xC-gen-proto.awk ../xC-gen-proto-go.awk ../xC-proto + awk -f ../xC-gen-proto.awk -f ../xC-gen-proto-go.awk ../xC-proto > $@ +public/proto.js: ../xC-gen-proto.awk ../xC-gen-proto-js.awk ../xC-proto + awk -f ../xC-gen-proto.awk -f ../xC-gen-proto-js.awk ../xC-proto > $@ +public/ircfmt.woff2: gen-ircfmt.awk + awk -v Output=$@ -f gen-ircfmt.awk +public/mithril.js: + curl -Lo $@ https://unpkg.com/mithril/mithril.js +clean: + rm -f $(outputs) diff --git a/xP/gen-ircfmt.awk b/xP/gen-ircfmt.awk new file mode 100644 index 0000000..cc9a5a0 --- /dev/null +++ b/xP/gen-ircfmt.awk @@ -0,0 +1,89 @@ +# gen-ircfmt.awk: generate a supplementary font for IRC formatting characters +# +# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +# SPDX-License-Identifier: 0BSD +# +# Usage: awk -v Output=static/ircfmt.woff2 -f gen-ircfmt.awk +# Clean up SVG byproducts yourself. + +BEGIN { + if (!Output) { + print "Error: you must specify the output filename" + exit 1 + } +} + +function glyph(name, code, path, filename, actions, cmd) { + filename = Output "." name ".svg" + + # Inkscape still does a terrible job at the stroke-to-path conversion. + actions = \ + "select-by-id:group;" \ + "selection-ungroup;" \ + "select-clear;" \ + "select-by-id:path;" \ + "object-stroke-to-path;" \ + "select-by-id:clip;" \ + "path-intersection;" \ + "select-all;" \ + "path-combine;" \ + "export-overwrite;" \ + "export-filename:" filename ";" \ + "export-do" + + # These dimensions fit FontForge defaults, and happen to work well. + cmd = "inkscape --pipe --actions='" actions "'" + print "<?xml version='1.0' encoding='UTF-8' standalone='no'?>\n" \ + "<svg version='1.1' xmlns='http://www.w3.org/2000/svg'\n" \ + " width='1000' height='1000' viewBox='0 0 1000 1000'>\n" \ + " <rect x='0' y='0' width='1000' height='1000' />\n" \ + " <g id='group' transform='translate(200 200) scale(60, 60)'>\n" \ + " <rect id='clip' x='0' y='0' width='10' height='10' />\n" \ + " <path id='path' stroke-width='2' fill='none' stroke='#fff'\n" \ + " d='" path "' />\n" \ + " </g>\n" \ + "</svg>\n" | cmd + close(cmd) + + print "Select(0u" code ")\n" \ + "Import('" filename "')" | FontForge +} + +BEGIN { + FontForge = "fontforge -lang=ff -" + print "New()" | FontForge + + # Designed a 10x10 raster, going for maximum simplicity. + glyph("B", "02", "m 6,5 c 0,0 2,0 2,2 0,2 -2,2 -2,2 h -3 v -8 h 2.5 c 0,0 2,0 2,2 0,2 -2,2 -2,2 h -2 Z") + glyph("C", "03", "m 7.6,7 A 3,4 0 0 1 4.25,8.875 3,4 0 0 1 2,5 3,4 0 0 1 4.25,1.125 3,4 0 0 1 7.6,3") + glyph("I", "1D", "m 3,9 h 4 m 0,-8 h -4 m 2,-1 v 10") + glyph("M", "11", "m 2,10 v -10 l 3,6 3,-6 v 10") + glyph("O", "0F", "m 1,9 l 8,-8 M 2,5 a 3,3 0 1 0 6,0 3,3 0 1 0 -6,0 z") + #glyph("R", "0F", "m 3,10 v -9 h 2 c 0,0 2.5,0 2.5,2.5 0,2.5 -2.5,2.5 -2.5,2.5 h -2 2.5 l 2.5,4.5") + glyph("S", "1E", "m 7.5,3 c 0,-1 -1,-2 -2.5,-2 -1.5,0 -2.5,1 -2.5,2 0,3 5,1 5,4 0,1 -1,2 -2.5,2 -1.5,0 -2.5,-1 -2.5,-2") + glyph("U", "1F", "m 2.5,0 v 6.5 c 0,1.5 1,2.5 2.5,2.5 1.5,0 2.5,-1 2.5,-2.5 v -6.5") + glyph("V", "16", "m 2,-1 3,11 3,-11") + + # In practice, your typical browser font will overshoot its em box, + # so to make the display more cohesive, we need to do the same. + # Sadly, sf->use_typo_metrics can't be unset from FontForge script-- + # this is necessary to prevent the caret from jumping upon the first + # inserted non-formatting character in xP's textarea. + # https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align + print "SelectAll()\n" \ + "Scale(115, 115, 0, 0)\n" \ + "SetOS2Value('WinAscentIsOffset', 1)\n" \ + "SetOS2Value('WinDescentIsOffset', 1)\n" \ + "SetOS2Value('HHeadAscentIsOffset', 1)\n" \ + "SetOS2Value('HHeadDescentIsOffset', 1)\n" \ + "CorrectDirection()\n" \ + "AutoWidth(100)\n" \ + "AutoHint()\n" \ + "AddExtrema()\n" \ + "RoundToInt()\n" \ + "SetFontNames('IRCFormatting-Regular'," \ + " 'IRC Formatting', 'IRC Formatting Regular', 'Regular'," \ + " 'Copyright (c) 2022, Premysl Eric Janouch')\n" \ + "Generate('" Output "')\n" | FontForge + close(FontForge) +} diff --git a/xP/go.mod b/xP/go.mod new file mode 100644 index 0000000..70cc10d --- /dev/null +++ b/xP/go.mod @@ -0,0 +1,10 @@ +module janouch.name/xK/xP + +go 1.18 + +require nhooyr.io/websocket v1.8.7 + +require ( + github.com/klauspost/compress v1.15.9 // indirect + golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect +) diff --git a/xP/go.sum b/xP/go.sum new file mode 100644 index 0000000..c94a673 --- /dev/null +++ b/xP/go.sum @@ -0,0 +1,62 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= +github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/xP/public/ircfmt.woff2 b/xP/public/ircfmt.woff2 Binary files differnew file mode 100644 index 0000000..d4262bc --- /dev/null +++ b/xP/public/ircfmt.woff2 diff --git a/xP/public/xP.css b/xP/public/xP.css new file mode 100644 index 0000000..e8b28f2 --- /dev/null +++ b/xP/public/xP.css @@ -0,0 +1,257 @@ +@font-face { + src: url('ircfmt.woff2') format('woff2'); + font-family: 'IRC Formatting'; + font-weight: normal; + font-style: normal; +} +body { + margin: 0; + padding: 0; + /* Firefox only renders C0 within the textarea, why? */ + font-family: 'IRC Formatting', sans-serif; + font-size: clamp(0.5rem, 2vw, 1rem); +} +.xP { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100vh; + /* https://caniuse.com/viewport-unit-variants */ + height: 100dvh; +} + +.overlay { + padding: .6em .9em; + background: #eee; + border: 1px outset #eee; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + z-index: 1; +} +.title, .status { + padding: .05em .3em; + background: #eee; + + display: flex; + justify-content: space-between; + align-items: baseline; + column-gap: .3em; + + position: relative; + border-top: 3px solid #ccc; + border-bottom: 2px solid #888; +} +.title { + /* To approximate right-aligned space-between. */ + flex-direction: row-reverse; +} +.title:before, .status:before { + content: " "; + position: absolute; + left: 0; + right: 0; + height: 2px; + top: -2px; + background: #fff; +} +.title:after, .status:after { + content: " "; + position: absolute; + left: 0; + right: 0; + height: 1px; + bottom: -1px; + background: #ccc; +} + +.toolbar { + display: flex; + align-items: baseline; + margin-right: -.3em; +} +.indicator { + margin: 0 .3em; +} +button { + font: inherit; + background: transparent; + border: 1px solid transparent; + padding: 0 .3em; +} +button:focus { + border: 1px dotted #000; +} +button:hover { + border-left: 1px solid #fff; + border-top: 1px solid #fff; + border-right: 1px solid #888; + border-bottom: 1px solid #888; +} +button:hover:active { + border-left: 1px solid #888; + border-top: 1px solid #888; + border-right: 1px solid #fff; + border-bottom: 1px solid #fff; +} + +.middle { + flex: auto; + display: flex; + flex-direction: row; + overflow: hidden; +} + +.list { + overflow-y: auto; + border-right: 2px solid #ccc; + min-width: 10em; + flex-shrink: 0; +} +.item { + padding: .05em .3em; + cursor: default; +} +.item.highlighted { + color: #ff5f00; +} +.item.activity { + font-weight: bold; +} +.item.current { + font-style: italic; + background: #eee; +} + +/* Only Firefox currently supports align-content: safe end, thus this. */ +.buffer-container { + flex: auto; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} +.filler { + flex: auto; +} +.buffer { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + overflow-y: auto; +} +.log { + font-family: monospace; + overflow-y: auto; +} +.log, .content, .completions { + /* Note: https://bugs.chromium.org/p/chromium/issues/detail?id=1261435 */ + white-space: break-spaces; + overflow-wrap: break-word; +} +.log, .buffer .content { + padding: .1em .3em; +} + +.leaked { + opacity: 50%; +} +.date { + padding: .3em; + grid-column: span 2; + font-weight: bold; +} +.unread { + grid-column: span 2; + border-top: 1px solid #ff5f00; +} +.time { + padding: .1em .3em; + background: #f8f8f8; + color: #bbb; + border-right: 1px solid #ccc; +} +.time.hidden:after { + border-top: .2em dotted #ccc; + display: block; + width: 50%; + margin: 0 auto; + content: ""; +} +.mark { + padding-right: .3em; + text-align: center; + display: inline-block; + min-width: 2em; +} +.mark.error { + color: red; +} +.mark.join { + color: green; +} +.mark.part { + color: red; +} +.mark.action { + color: darkred; +} +.content .b { + font-weight: bold; +} +.content .i { + font-style: italic; +} +.content .u { + text-decoration: underline; +} +.content .s { + text-decoration: line-through; +} +.content .m { + font-family: monospace; +} + +.completions { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: #fff; + padding: .05em .3em; + border-top: 1px solid #888; + + max-height: 50%; + display: flex; + flex-flow: column wrap; + column-gap: .6em; + overflow-x: auto; +} +.input { + flex-shrink: 0; + border: 2px inset #eee; + overflow: hidden; + resize: vertical; + display: flex; +} +.input:focus-within { + border-color: #ff5f00; +} +.prompt { + padding: .05em .3em; + border-right: 1px solid #ccc; + background: #f8f8f8; + font-weight: bold; +} +textarea { + font: inherit; + padding: .05em .3em; + margin: 0; + border: 0; + flex-grow: 1; + resize: none; +} +textarea:focus { + outline: none; +} diff --git a/xP/public/xP.js b/xP/public/xP.js new file mode 100644 index 0000000..3266063 --- /dev/null +++ b/xP/public/xP.js @@ -0,0 +1,1108 @@ +// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +// SPDX-License-Identifier: 0BSD +import * as Relay from './proto.js' + +// ---- RPC -------------------------------------------------------------------- + +class RelayRPC extends EventTarget { + constructor(url) { + super() + this.url = url + this.commandSeq = 0 + } + + connect() { + // We can't close the connection immediately, as that queues a task. + if (this.ws !== undefined) + throw "Already connecting or connected" + + return new Promise((resolve, reject) => { + let ws = this.ws = new WebSocket(this.url) + ws.onopen = event => { + this._initialize() + resolve() + } + // It's going to be code 1006 with no further info. + ws.onclose = event => { + this.ws = undefined + reject() + } + }) + } + + _initialize() { + this.ws.binaryType = 'arraybuffer' + this.ws.onopen = undefined + this.ws.onmessage = event => { + this._process(event.data) + } + this.ws.onerror = event => { + this.dispatchEvent(new CustomEvent('error')) + } + this.ws.onclose = event => { + let message = "Connection closed: " + + event.reason + " (" + event.code + ")" + for (const seq in this.promised) + this.promised[seq].reject(message) + + this.ws = undefined + this.dispatchEvent(new CustomEvent('close', { + detail: {message, code: event.code, reason: event.reason}, + })) + + // Now connect() can be called again. + } + + this.promised = {} + } + + _process(data) { + if (typeof data === 'string') + throw "JSON messages not supported" + + const r = new Relay.Reader(data) + while (!r.empty) + this._processOne(Relay.EventMessage.deserialize(r)) + } + + _processOne(message) { + let e = message.data + switch (e.event) { + case Relay.Event.Error: + if (this.promised[e.commandSeq] !== undefined) + this.promised[e.commandSeq].reject(e.error) + else + console.error(`Unawaited error: ${e.error}`) + break + case Relay.Event.Response: + if (this.promised[e.commandSeq] !== undefined) + this.promised[e.commandSeq].resolve(e.data) + else + console.error("Unawaited response") + break + default: + e.eventSeq = message.eventSeq + this.dispatchEvent(new CustomEvent('event', {detail: e})) + return + } + + delete this.promised[e.commandSeq] + for (const seq in this.promised) { + // We don't particularly care about wraparound issues. + if (seq >= e.commandSeq) + continue + + this.promised[seq].reject("No response") + delete this.promised[seq] + } + } + + send(params) { + if (this.ws === undefined) + throw "Not connected" + if (typeof params !== 'object') + throw "Method parameters must be an object" + + // Left shifts in Javascript convert to a 32-bit signed number. + let seq = ++this.commandSeq + if ((seq << 0) != seq) + seq = this.commandSeq = 0 + + this.ws.send(JSON.stringify({commandSeq: seq, data: params})) + + // Automagically detect if we want a result. + let data = undefined + const promise = new Promise( + (resolve, reject) => { data = {resolve, reject} }) + promise.then = (...args) => { + this.promised[seq] = data + return Promise.prototype.then.call(promise, ...args) + } + return promise + } +} + +// ---- Utilities -------------------------------------------------------------- + +function utf8Encode(s) { return new TextEncoder().encode(s) } +function utf8Decode(s) { return new TextDecoder().decode(s) } + +function hasShortcutModifiers(event) { + return (event.altKey || event.escapePrefix) && + !event.metaKey && !event.ctrlKey +} + +const audioContext = new AudioContext() + +function beep() { + let gain = audioContext.createGain() + gain.gain.value = 0.5 + gain.connect(audioContext.destination) + + let oscillator = audioContext.createOscillator() + oscillator.type = "triangle" + oscillator.frequency.value = 800 + oscillator.connect(gain) + oscillator.start(audioContext.currentTime) + oscillator.stop(audioContext.currentTime + 0.1) +} + +let iconLink = undefined +let iconState = undefined + +function updateIcon(highlighted) { + if (iconState === highlighted) + return + + iconState = highlighted + let canvas = document.createElement('canvas') + canvas.width = 32 + canvas.height = 32 + + let ctx = canvas.getContext('2d') + ctx.arc(16, 16, 12, 0, 2 * Math.PI) + ctx.fillStyle = '#000' + if (highlighted === true) + ctx.fillStyle = '#ff5f00' + if (highlighted === false) + ctx.fillStyle = '#ccc' + ctx.fill() + + if (iconLink === undefined) { + iconLink = document.createElement('link') + iconLink.type = 'image/png' + iconLink.rel = 'icon' + document.getElementsByTagName('head')[0].appendChild(iconLink) + } + + iconLink.href = canvas.toDataURL(); +} + +// ---- Event processing ------------------------------------------------------- + +let rpc = new RelayRPC(proxy) +let rpcEventHandlers = new Map() + +let buffers = new Map() +let bufferLast = undefined +let bufferCurrent = undefined +let bufferLog = undefined +let bufferAutoscroll = true + +let servers = new Map() + +function bufferResetStats(b) { + b.newMessages = 0 + b.newUnimportantMessages = 0 + b.highlighted = false +} + +function bufferActivate(name) { + rpc.send({command: 'BufferActivate', bufferName: name}) +} + +function bufferToggleUnimportant(name) { + rpc.send({command: 'BufferToggleUnimportant', bufferName: name}) +} + +function bufferToggleLog() { + // TODO: Try to restore the previous scroll offset. + if (bufferLog) { + setTimeout(() => + document.getElementById('input')?.focus()) + + bufferLog = undefined + m.redraw() + return + } + + let name = bufferCurrent + rpc.send({ + command: 'BufferLog', + bufferName: name, + }).then(resp => { + if (bufferCurrent !== name) + return + + bufferLog = utf8Decode(resp.log) + m.redraw() + }) +} + +let connecting = true +rpc.connect().then(result => { + buffers.clear() + bufferLast = undefined + bufferCurrent = undefined + bufferLog = undefined + bufferAutoscroll = true + + servers.clear() + + rpc.send({command: 'Hello', version: Relay.version}) + connecting = false + m.redraw() +}).catch(error => { + connecting = false + m.redraw() +}) + +rpc.addEventListener('close', event => { + m.redraw() +}) + +rpc.addEventListener('event', event => { + const handler = rpcEventHandlers.get(event.detail.event) + if (handler !== undefined) { + handler(event.detail) + if (bufferCurrent !== undefined || + event.detail.event !== Relay.Event.BufferLine) + m.redraw() + } +}) + +rpcEventHandlers.set(Relay.Event.Ping, e => { + rpc.send({command: 'PingResponse', eventSeq: e.eventSeq}) +}) + +// ~~~ Buffer events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +rpcEventHandlers.set(Relay.Event.BufferLine, e => { + let b = buffers.get(e.bufferName), line = {...e} + delete line.event + delete line.eventSeq + delete line.leakToActive + if (b === undefined) + return + + // Initial sync: skip all other processing, let highlights be. + if (bufferCurrent === undefined) { + b.lines.push(line) + return + } + + let visible = document.visibilityState !== 'hidden' && + bufferLog === undefined && + bufferAutoscroll && + (e.bufferName == bufferCurrent || e.leakToActive) + b.lines.push({...line}) + if (!(visible || e.leakToActive) || + b.newMessages || b.newUnimportantMessages) { + if (line.isUnimportant) + b.newUnimportantMessages++ + else + b.newMessages++ + } + + if (e.leakToActive) { + let bc = buffers.get(bufferCurrent) + bc.lines.push({...line, leaked: true}) + if (!visible || bc.newMessages || bc.newUnimportantMessages) { + if (line.isUnimportant) + bc.newUnimportantMessages++ + else + bc.newMessages++ + } + } + + if (line.isHighlight || (!visible && !line.isUnimportant && + b.kind === Relay.BufferKind.PrivateMessage)) { + beep() + if (!visible) + b.highlighted = true + } +}) + +rpcEventHandlers.set(Relay.Event.BufferUpdate, e => { + let b = buffers.get(e.bufferName) + if (b === undefined) { + buffers.set(e.bufferName, (b = { + lines: [], + history: [], + historyAt: 0, + })) + bufferResetStats(b) + } + + b.hideUnimportant = e.hideUnimportant + b.kind = e.context.kind + b.server = servers.get(e.context.serverName) + b.topic = e.context.topic + b.modes = e.context.modes +}) + +rpcEventHandlers.set(Relay.Event.BufferStats, e => { + let b = buffers.get(e.bufferName) + if (b === undefined) + return + + b.newMessages = e.newMessages, + b.newUnimportantMessages = e.newUnimportantMessages + b.highlighted = e.highlighted +}) + +rpcEventHandlers.set(Relay.Event.BufferRename, e => { + buffers.set(e.new, buffers.get(e.bufferName)) + buffers.delete(e.bufferName) +}) + +rpcEventHandlers.set(Relay.Event.BufferRemove, e => { + buffers.delete(e.bufferName) + if (e.bufferName === bufferLast) + bufferLast = undefined +}) + +rpcEventHandlers.set(Relay.Event.BufferActivate, e => { + let old = buffers.get(bufferCurrent) + if (old !== undefined) + bufferResetStats(old) + + bufferLast = bufferCurrent + let b = buffers.get(e.bufferName) + bufferCurrent = e.bufferName + bufferLog = undefined + bufferAutoscroll = true + if (b !== undefined && document.visibilityState !== 'hidden') + b.highlighted = false + + let textarea = document.getElementById('input') + if (textarea === null) + return + + textarea.focus() + if (old !== undefined) { + old.input = textarea.value + old.inputStart = textarea.selectionStart + old.inputEnd = textarea.selectionEnd + old.inputDirection = textarea.selectionDirection + // Note that we effectively overwrite the newest line + // with the current textarea contents, and jump there. + old.historyAt = old.history.length + } + + textarea.value = '' + if (b !== undefined && b.input !== undefined) { + textarea.value = b.input + textarea.setSelectionRange(b.inputStart, b.inputEnd, b.inputDirection) + } +}) + +rpcEventHandlers.set(Relay.Event.BufferClear, e => { + let b = buffers.get(e.bufferName) + if (b !== undefined) + b.lines.length = 0 +}) + +// ~~~ Server events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +rpcEventHandlers.set(Relay.Event.ServerUpdate, e => { + let s = servers.get(e.serverName) + if (s === undefined) + servers.set(e.serverName, (s = {})) + s.data = e.data +}) + +rpcEventHandlers.set(Relay.Event.ServerRename, e => { + servers.set(e.new, servers.get(e.serverName)) + servers.delete(e.serverName) +}) + +rpcEventHandlers.set(Relay.Event.ServerRemove, e => { + servers.delete(e.serverName) +}) + +// --- Colours ----------------------------------------------------------------- + +let palette = [ + '#000', '#800', '#080', '#880', '#008', '#808', '#088', '#ccc', + '#888', '#f00', '#0f0', '#ff0', '#00f', '#f0f', '#0ff', '#fff', +] +palette.length = 256 +for (let i = 0; i < 216; i++) { + let r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6 + r = !r ? '00' : (55 + 40 * r).toString(16) + g = !g ? '00' : (55 + 40 * g).toString(16) + b = !b ? '00' : (55 + 40 * b).toString(16) + palette[16 + i] = `#${r}${g}${b}` +} +for (let i = 0; i < 24; i++) { + let g = ('0' + (8 + i * 10).toString(16)).slice(-2) + palette[232 + i] = `#${g}${g}${g}` +} + +// ---- UI --------------------------------------------------------------------- + +let linkRE = [ + /https?:\/\//, + /([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/, + /([^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\))/, +].map(r => r.source).join('') + +let BufferList = { + view: vnode => { + let highlighted = false + let items = Array.from(buffers, ([name, b]) => { + let classes = [], displayName = name + if (name == bufferCurrent) { + classes.push('current') + } else if (b.newMessages) { + classes.push('activity') + displayName += ` (${b.newMessages})` + } + if (b.highlighted) { + classes.push('highlighted') + highlighted = true + } + return m('.item', { + onclick: event => bufferActivate(name), + class: classes.join(' '), + }, displayName) + }) + + updateIcon(rpc.ws === undefined ? null : highlighted) + return m('.list', {}, items) + }, +} + +let Content = { + applyColor: (fg, bg, inverse) => { + if (inverse) + [fg, bg] = [bg >= 0 ? bg : 15, fg >= 0 ? fg : 0] + + let style = {} + if (fg >= 0) + style.color = palette[fg] + if (bg >= 0) + style.backgroundColor = palette[bg] + for (const _ in style) + return style + }, + + linkify: (text, attrs) => { + let re = new RegExp(linkRE, 'g'), a = [], end = 0, match + while ((match = re.exec(text)) !== null) { + if (end < match.index) + a.push(m('span', attrs, text.substring(end, match.index))) + a.push(m('a[target=_blank]', {href: match[0], ...attrs}, match[0])) + end = re.lastIndex + } + if (end < text.length) + a.push(m('span', attrs, text.substring(end))) + return a + }, + + makeMark: line => { + switch (line.rendition) { + case Relay.Rendition.Indent: return m('span.mark', {}, '') + case Relay.Rendition.Status: return m('span.mark', {}, '–') + case Relay.Rendition.Error: return m('span.mark.error', {}, '⚠') + case Relay.Rendition.Join: return m('span.mark.join', {}, '→') + case Relay.Rendition.Part: return m('span.mark.part', {}, '←') + case Relay.Rendition.Action: return m('span.mark.action', {}, '✶') + } + }, + + view: vnode => { + let line = vnode.children[0] + let classes = new Set() + let flip = c => { + if (classes.has(c)) + classes.delete(c) + else + classes.add(c) + } + + let fg = -1, bg = -1, inverse = false + return m('.content', vnode.attrs, [ + Content.makeMark(line), + line.items.flatMap(item => { + switch (item.kind) { + case Relay.Item.Text: + return Content.linkify(item.text, { + class: Array.from(classes.keys()).join(' '), + style: Content.applyColor(fg, bg, inverse), + }) + case Relay.Item.Reset: + classes.clear() + fg = bg = -1 + inverse = false + break + case Relay.Item.FgColor: + fg = item.color + break + case Relay.Item.BgColor: + bg = item.color + break + case Relay.Item.FlipInverse: + inverse = !inverse + break + case Relay.Item.FlipBold: + flip('b') + break + case Relay.Item.FlipItalic: + flip('i') + break + case Relay.Item.FlipUnderline: + flip('u') + break + case Relay.Item.FlipCrossedOut: + flip('s') + break + case Relay.Item.FlipMonospace: + flip('m') + break + } + }), + ]) + }, +} + +let Topic = { + view: vnode => { + let b = buffers.get(bufferCurrent) + if (b !== undefined && b.topic !== undefined) + return m(Content, {}, {items: b.topic}) + }, +} + +let Buffer = { + controller: new AbortController(), + + onbeforeremove: vnode => { + Buffer.controller.abort() + }, + + onupdate: vnode => { + if (bufferAutoscroll) + vnode.dom.scrollTop = vnode.dom.scrollHeight + }, + + oncreate: vnode => { + Buffer.onupdate(vnode) + window.addEventListener('resize', event => Buffer.onupdate(vnode), + {signal: Buffer.controller.signal}) + }, + + view: vnode => { + let lines = [] + let b = buffers.get(bufferCurrent) + if (b === undefined) + return m('.buffer') + + let lastDateMark = undefined + let squashing = false + let markBefore = b.lines.length + - b.newMessages - b.newUnimportantMessages + b.lines.forEach((line, i) => { + if (i == markBefore) + lines.push(m('.unread')) + + if (!line.isUnimportant || !b.hideUnimportant) { + squashing = false + } else if (squashing) { + return + } else { + squashing = true + } + + let date = new Date(line.when) + let dateMark = date.toLocaleDateString() + if (dateMark !== lastDateMark) { + lines.push(m('.date', {}, dateMark)) + lastDateMark = dateMark + } + if (squashing) { + lines.push(m('.time.hidden')) + lines.push(m('.content')) + return + } + + let attrs = {} + if (line.leaked) + attrs.class = 'leaked' + + lines.push(m('.time', {...attrs}, date.toLocaleTimeString())) + lines.push(m(Content, {...attrs}, line)) + }) + + let dateMark = new Date().toLocaleDateString() + if (dateMark !== lastDateMark && lastDateMark !== undefined) + lines.push(m('.date', {}, dateMark)) + return m('.buffer', {onscroll: event => { + const dom = event.target + bufferAutoscroll = + dom.scrollTop + dom.clientHeight + 1 >= dom.scrollHeight + }}, lines) + }, +} + +let Log = { + oncreate: vnode => { + vnode.dom.scrollTop = vnode.dom.scrollHeight + vnode.dom.focus() + }, + + linkify: text => { + let re = new RegExp(linkRE, 'g'), a = [], end = 0, match + while ((match = re.exec(text)) !== null) { + if (end < match.index) + a.push(text.substring(end, match.index)) + a.push(m('a[target=_blank]', {href: match[0]}, match[0])) + end = re.lastIndex + } + if (end < text.length) + a.push(text.substring(end)) + return a + }, + + view: vnode => { + return m(".log", {}, Log.linkify(bufferLog)) + }, +} + +let Completions = { + entries: [], + + reset: list => { + Completions.entries = list || [] + m.redraw() + }, + + view: vnode => { + if (!Completions.entries.length) + return + return m('.completions', {}, + Completions.entries.map(option => m('.completion', {}, option))) + }, +} + +let BufferContainer = { + view: vnode => { + return m('.buffer-container', {}, [ + m('.filler'), + bufferLog !== undefined ? m(Log) : m(Buffer), + m(Completions), + ]) + }, +} + +let Toolbar = { + format: formatting => { + let textarea = document.getElementById('input') + if (textarea !== null) + Input.format(textarea, formatting) + }, + + view: vnode => { + let indicators = [] + if (bufferLog === undefined && !bufferAutoscroll) + indicators.push(m('.indicator', {}, '⇩')) + if (Input.formatting) + indicators.push(m('.indicator', {}, '#')) + return m('.toolbar', {}, [ + indicators, + m('button', {onclick: event => Toolbar.format('\u0002')}, + m('b', {}, 'B')), + m('button', {onclick: event => Toolbar.format('\u001D')}, + m('i', {}, 'I')), + m('button', {onclick: event => Toolbar.format('\u001F')}, + m('u', {}, 'U')), + m('button', {onclick: event => bufferToggleLog()}, + bufferLog === undefined ? 'Log' : 'Hide log'), + ]) + }, +} + +let Status = { + view: vnode => { + let b = buffers.get(bufferCurrent) + if (b === undefined) + return m('.status', {}, 'Synchronizing...') + + let status = `${bufferCurrent}` + if (b.modes) + status += `(+${b.modes})` + if (b.hideUnimportant) + status += `<H>` + return m('.status', {}, [status, m(Toolbar)]) + }, +} + +let Prompt = { + view: vnode => { + let b = buffers.get(bufferCurrent) + if (b === undefined || b.server === undefined) + return + + if (b.server.data.user !== undefined) { + let user = b.server.data.user + if (b.server.data.userModes) + user += `(${b.server.data.userModes})` + return m('.prompt', {}, `${user}`) + } + + // This might certainly be done more systematically. + let state = b.server.data.state + for (const s in Relay.ServerState) + if (Relay.ServerState[s] == state) { + state = s + break + } + return m('.prompt', {}, `(${state})`) + }, +} + +let Input = { + counter: 0, + stamp: textarea => { + return [Input.counter, + textarea.selectionStart, textarea.selectionEnd, textarea.value] + }, + + complete: (b, textarea) => { + if (textarea.selectionStart !== textarea.selectionEnd) + return false + + // Cancel any previous autocomplete, and ensure applicability. + Input.counter++ + let state = Input.stamp(textarea) + rpc.send({ + command: 'BufferComplete', + bufferName: bufferCurrent, + text: textarea.value, + position: utf8Encode( + textarea.value.slice(0, textarea.selectionEnd)).length, + }).then(resp => { + if (!Input.stamp(textarea).every((v, k) => v === state[k])) + return + + let preceding = utf8Encode(textarea.value).slice(0, resp.start) + let start = utf8Decode(preceding).length + if (resp.completions.length > 0) { + textarea.setRangeText(resp.completions[0], + start, textarea.selectionEnd, 'end') + } + + if (resp.completions.length == 1) { + textarea.setRangeText(' ', + textarea.selectionStart, textarea.selectionEnd, 'end') + } else { + beep() + } + + if (resp.completions.length > 1) + Completions.reset(resp.completions.slice(1)) + }) + return true + }, + + submit: (b, textarea) => { + rpc.send({ + command: 'BufferInput', + bufferName: bufferCurrent, + text: textarea.value, + }) + + // b.history[b.history.length] is virtual, and is represented + // either by textarea contents when it's currently being edited, + // or by b.input in all other cases. + b.history.push(textarea.value) + b.historyAt = b.history.length + textarea.value = '' + return true + }, + + backward: textarea => { + if (textarea.selectionStart !== textarea.selectionEnd) + return false + + let point = textarea.selectionStart + if (point < 1) + return false + while (point && /\s/.test(textarea.value.charAt(--point))) {} + while (point-- && !/\s/.test(textarea.value.charAt(point))) {} + point++ + textarea.setSelectionRange(point, point) + return true + }, + + forward: textarea => { + if (textarea.selectionStart !== textarea.selectionEnd) + return false + + let point = textarea.selectionStart, len = textarea.value.length + if (point + 1 > len) + return false + while (point < len && /\s/.test(textarea.value.charAt(point))) point++ + while (point < len && !/\s/.test(textarea.value.charAt(point))) point++ + textarea.setSelectionRange(point, point) + return true + }, + + modifyWord: (textarea, cb) => { + let start = textarea.selectionStart + let end = textarea.selectionEnd + if (start === end) { + let len = textarea.value.length + while (start < len && /\s/.test(textarea.value.charAt(start))) + start++; + end = start + while (end < len && !/\s/.test(textarea.value.charAt(end))) + end++; + } + if (start === end) + return false + + const text = textarea.value, modified = cb(text.substring(start, end)) + textarea.value = text.slice(0, start) + modified + text.slice(end) + end = start + modified.length + textarea.setSelectionRange(end, end) + return true + }, + + downcase: textarea => { + return Input.modifyWord(textarea, text => text.toLowerCase()) + }, + + upcase: textarea => { + return Input.modifyWord(textarea, text => text.toUpperCase()) + }, + + capitalize: textarea => { + return Input.modifyWord(textarea, text => { + const cps = Array.from(text.toLowerCase()) + return cps[0].toUpperCase() + cps.slice(1).join('') + }) + }, + + first: (b, textarea) => { + if (b.historyAt <= 0) + return false + + if (b.historyAt == b.history.length) + b.input = textarea.value + textarea.value = b.history[(b.historyAt = 0)] + return true + }, + + last: (b, textarea) => { + if (b.historyAt >= b.history.length) + return false + + b.historyAt = b.history.length + textarea.value = b.input + return true + }, + + previous: (b, textarea) => { + if (b.historyAt <= 0) + return false + + if (b.historyAt == b.history.length) + b.input = textarea.value + textarea.value = b.history[--b.historyAt] + return true + }, + + next: (b, textarea) => { + if (b.historyAt >= b.history.length) + return false + + if (++b.historyAt == b.history.length) + textarea.value = b.input + else + textarea.value = b.history[b.historyAt] + return true + }, + + formatting: false, + + format: (textarea, formatting) => { + const [start, end] = [textarea.selectionStart, textarea.selectionEnd] + if (start === end) { + textarea.setRangeText(formatting) + textarea.setSelectionRange( + start + formatting.length, end + formatting.length) + } else { + textarea.setRangeText( + formatting + textarea.value.substr(start, end) + formatting) + } + textarea.focus() + }, + + onKeyDown: event => { + // TODO: And perhaps on other actions, too. + rpc.send({command: 'Active'}) + + let b = buffers.get(bufferCurrent) + if (b === undefined) + return + + let textarea = event.currentTarget + let handled = false + let success = true + if (Input.formatting) { + Input.formatting = false + + // Like process_formatting_escape() within xC. + handled = true + switch (event.key) { + case 'b': Input.format(textarea, '\u0002'); break + case 'c': Input.format(textarea, '\u0003'); break + case 'q': + case 'm': Input.format(textarea, '\u0011'); break + case 'v': Input.format(textarea, '\u0016'); break + case 'i': + case ']': Input.format(textarea, '\u001D'); break + case 's': + case 'x': + case '^': Input.format(textarea, '\u001E'); break + case 'u': + case '_': Input.format(textarea, '\u001F'); break + case 'r': + case 'o': Input.format(textarea, '\u000F'); break + default: success = false + } + } else if (hasShortcutModifiers(event)) { + handled = true + switch (event.key) { + case 'b': success = Input.backward(textarea); break + case 'f': success = Input.forward(textarea); break + case 'l': success = Input.downcase(textarea); break + case 'u': success = Input.upcase(textarea); break + case 'c': success = Input.capitalize(textarea); break + case '<': success = Input.first(b, textarea); break + case '>': success = Input.last(b, textarea); break + case 'p': success = Input.previous(b, textarea); break + case 'n': success = Input.next(b, textarea); break + case 'm': success = Input.formatting = true; break + default: handled = false + } + } else if (!event.altKey && !event.ctrlKey && !event.metaKey && + !event.shiftKey) { + handled = true + switch (event.keyCode) { + case 9: success = Input.complete(b, textarea); break + case 13: success = Input.submit(b, textarea); break + default: handled = false + } + } + if (!success) + beep() + if (handled) + event.preventDefault() + }, + + onStateChange: event => { + Completions.reset() + Input.formatting = false + }, + + view: vnode => { + return m('textarea#input', { + rows: 1, + onkeydown: Input.onKeyDown, + oninput: Input.onStateChange, + // Sadly only supported in Firefox as of writing. + onselectionchange: Input.onStateChange, + // The list of completions is scrollable without receiving focus. + onblur: Input.onStateChange, + }) + }, +} + +let Main = { + view: vnode => { + let overlay = undefined + if (connecting) + overlay = m('.overlay', {}, "Connecting...") + else if (rpc.ws === undefined) + overlay = m('.overlay', {}, [ + m('', {}, "Disconnected"), + m('', {}, m('small', {}, "Reload page to reconnect.")), + ]) + + return m('.xP', {}, [ + overlay, + m('.title', {}, [m('b', {}, `xP`), m(Topic)]), + m('.middle', {}, [m(BufferList), m(BufferContainer)]), + m(Status), + m('.input', {}, [m(Prompt), m(Input)]), + ]) + }, +} + +window.addEventListener('load', () => m.mount(document.body, Main)) + +document.addEventListener('visibilitychange', event => { + let b = buffers.get(bufferCurrent) + if (b !== undefined && document.visibilityState !== 'hidden') { + b.highlighted = false + m.redraw() + } +}) + +// On macOS, the Alt/Option key transforms characters, which basically breaks +// all event.altKey shortcuts, so implement Escape prefixing on that system. +// This method of detection only works with Blink browsers, as of writing. +let lastWasEscape = false +document.addEventListener('keydown', event => { + event.escapePrefix = lastWasEscape + if (lastWasEscape) { + lastWasEscape = false + } else if (event.code == 'Escape' && + navigator.userAgentData?.platform === 'macOS') { + event.preventDefault() + event.stopPropagation() + lastWasEscape = true + return + } + + if (rpc.ws == undefined || !hasShortcutModifiers(event)) + return + + // Rotate names so that the current buffer comes first. + let names = [...buffers.keys()] + names.push.apply(names, + names.splice(0, names.findIndex(name => name == bufferCurrent))) + + switch (event.key) { + case 'h': + bufferToggleLog() + break + case 'H': + if (bufferCurrent !== undefined) + bufferToggleUnimportant(bufferCurrent) + break + case 'a': + for (const name of names.slice(1)) + if (buffers.get(name).newMessages) { + bufferActivate(name) + break + } + break + case '!': + for (const name of names.slice(1)) + if (buffers.get(name).highlighted) { + bufferActivate(name) + break + } + break + case 'Tab': + if (bufferLast !== undefined) + bufferActivate(bufferLast) + break + case 'PageUp': + if (names.length > 1) + bufferActivate(names.at(-1)) + break + case 'PageDown': + if (names.length > 1) + bufferActivate(names.at(+1)) + break + default: + return + } + + event.preventDefault() +}, true) diff --git a/xP/xP.go b/xP/xP.go new file mode 100644 index 0000000..20117b2 --- /dev/null +++ b/xP/xP.go @@ -0,0 +1,299 @@ +// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +// SPDX-License-Identifier: 0BSD + +package main + +import ( + "bufio" + "context" + "encoding/binary" + "encoding/json" + "flag" + "fmt" + "html/template" + "io" + "log" + "net" + "net/http" + "os" + "time" + + "nhooyr.io/websocket" +) + +var ( + debug = flag.Bool("debug", false, "enable debug output") + + addressBind string + addressConnect string + addressWS string +) + +// ----------------------------------------------------------------------------- + +func relayReadFrame(r io.Reader) []byte { + var length uint32 + if err := binary.Read(r, binary.BigEndian, &length); err != nil { + log.Println("Event receive failed: " + err.Error()) + return nil + } + b := make([]byte, length) + if _, err := io.ReadFull(r, b); err != nil { + log.Println("Event receive failed: " + err.Error()) + return nil + } + + if *debug { + log.Printf("<? %v\n", b) + + var m RelayEventMessage + if after, ok := m.ConsumeFrom(b); !ok { + log.Println("Event deserialization failed") + return nil + } else if len(after) != 0 { + log.Println("Event deserialization failed: trailing data") + return nil + } + + j, err := m.MarshalJSON() + if err != nil { + log.Println("Event marshalling failed: " + err.Error()) + return nil + } + + log.Printf("<- %s\n", j) + } + return b +} + +func relayMakeReceiver(ctx context.Context, conn net.Conn) <-chan []byte { + // The usual event message rarely gets above 1 kilobyte, + // thus this is set to buffer up at most 1 megabyte or so. + p := make(chan []byte, 1000) + r := bufio.NewReaderSize(conn, 65536) + go func() { + defer close(p) + for { + j := relayReadFrame(r) + if j == nil { + return + } + select { + case p <- j: + case <-ctx.Done(): + return + } + } + }() + return p +} + +func relayWriteJSON(conn net.Conn, j []byte) bool { + var m RelayCommandMessage + if err := json.Unmarshal(j, &m); err != nil { + log.Println("Command unmarshalling failed: " + err.Error()) + return false + } + + b, ok := m.AppendTo(make([]byte, 4)) + if !ok { + log.Println("Command serialization failed") + return false + } + binary.BigEndian.PutUint32(b[:4], uint32(len(b)-4)) + if _, err := conn.Write(b); err != nil { + log.Println("Command send failed: " + err.Error()) + return false + } + + if *debug { + log.Printf("-> %v\n", b) + } + return true +} + +// ----------------------------------------------------------------------------- + +func clientReadJSON(ctx context.Context, ws *websocket.Conn) []byte { + t, j, err := ws.Read(ctx) + if err != nil { + log.Println("Command receive failed: " + err.Error()) + return nil + } + if t != websocket.MessageText { + log.Println( + "Command receive failed: " + "binary messages are not supported") + return nil + } + + if *debug { + log.Printf("?> %s\n", j) + } + return j +} + +func clientWriteBinary(ctx context.Context, ws *websocket.Conn, b []byte) bool { + if err := ws.Write(ctx, websocket.MessageBinary, b); err != nil { + log.Println("Event send failed: " + err.Error()) + return false + } + return true +} + +func clientWriteError(ctx context.Context, ws *websocket.Conn, err error) bool { + b, ok := (&RelayEventMessage{ + EventSeq: 0, + Data: RelayEventData{ + Interface: RelayEventDataError{ + Event: RelayEventError, + CommandSeq: 0, + Error: err.Error(), + }, + }, + }).AppendTo(nil) + if ok { + log.Println("Event serialization failed") + return false + } + return clientWriteBinary(ctx, ws, b) +} + +func handleWS(w http.ResponseWriter, r *http.Request) { + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + // Note that Safari can be broken with compression. + CompressionMode: websocket.CompressionContextTakeover, + // This is for the payload; set higher to avoid overhead. + CompressionThreshold: 64 << 10, + }) + if err != nil { + log.Println("Client rejected: " + err.Error()) + return + } + defer ws.Close(websocket.StatusGoingAway, "Goodbye") + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + conn, err := net.Dial("tcp", addressConnect) + if err != nil { + log.Println("Connection failed: " + err.Error()) + clientWriteError(ctx, ws, err) + return + } + defer conn.Close() + + // To decrease latencies, events are received and decoded in parallel + // to their sending, and we try to batch them together. + relayFrames := relayMakeReceiver(ctx, conn) + batchFrames := func() []byte { + batch, ok := <-relayFrames + if !ok { + return nil + } + Batch: + for { + select { + case b, ok := <-relayFrames: + if !ok { + break Batch + } + batch = append(batch, b...) + default: + break Batch + } + } + return batch + } + + // We don't need to intervene, so it's just two separate pipes so far. + go func() { + defer cancel() + for { + j := clientReadJSON(ctx, ws) + if j == nil { + return + } + relayWriteJSON(conn, j) + } + }() + go func() { + defer cancel() + for { + b := batchFrames() + if b == nil { + return + } + clientWriteBinary(ctx, ws, b) + } + }() + <-ctx.Done() +} + +// ----------------------------------------------------------------------------- + +var staticHandler = http.FileServer(http.Dir(".")) + +var page = template.Must(template.New("/").Parse(`<!DOCTYPE html> +<html> +<head> + <title>xP</title> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" href="xP.css" /> +</head> +<body> + <script src="mithril.js"> + </script> + <script> + let proxy = '{{ . }}' + </script> + <script type="module" src="xP.js"> + </script> +</body> +</html>`)) + +func handleDefault(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + staticHandler.ServeHTTP(w, r) + return + } + + wsURI := addressWS + if wsURI == "" { + wsURI = fmt.Sprintf("ws://%s/ws", r.Host) + } + if err := page.Execute(w, wsURI); err != nil { + log.Println("Template execution failed: " + err.Error()) + } +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), + "Usage: %s [OPTION...] BIND CONNECT [WSURI]\n\n", os.Args[0]) + flag.PrintDefaults() + } + + flag.Parse() + if flag.NArg() < 2 || flag.NArg() > 3 { + flag.Usage() + os.Exit(1) + } + + addressBind, addressConnect = flag.Arg(0), flag.Arg(1) + if flag.NArg() > 2 { + addressWS = flag.Arg(2) + } + + http.Handle("/ws", http.HandlerFunc(handleWS)) + http.Handle("/", http.HandlerFunc(handleDefault)) + + s := &http.Server{ + Addr: addressBind, + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + MaxHeaderBytes: 32 << 10, + } + log.Fatal(s.ListenAndServe()) +} |