diff --git a/CMakeLists.txt b/CMakeLists.txt index dd6bfee..9c0c319 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.10) -project(fun VERSION 0.41.12 LANGUAGES C) +project(fun VERSION 0.41.13 LANGUAGES C) set(CMAKE_C_STANDARD 99) set(CMAKE_C_STANDARD_REQUIRED ON) diff --git a/examples/net/irc_interactive.fun b/examples/net/irc_interactive.fun new file mode 100644 index 0000000..a954a1d --- /dev/null +++ b/examples/net/irc_interactive.fun @@ -0,0 +1,487 @@ +#!/usr/bin/env fun + +/* + * Fully interactive IRC client example using lib/net/irc.fun + * + * This script lets you interact with an IRC server from the terminal: + * - Shows incoming messages (processed between your inputs) + * - Lets you type slash-commands similar to classic IRC clients + * + * Notes/limitations: + * - The Fun stdlib provides blocking input(); there is no stdin polling. + * We therefore alternate: process network I/O, then read one user line. + * Server PINGs are handled whenever we process network I/O (between inputs). + * - For TLS, point host/port to a local TLS tunnel (e.g., stunnel) and set use_tls=1. + */ + +#include +#include +#include +#include +#include + +// --- Configuration --- +// freenode (plain): host="chat.freenode.net", port=6667, use_tls=0 +// For TLS via a local tunnel to server:6697 -> host="127.0.0.1", port=, use_tls=1 +host = "chat.freenode.net" +port = 6667 +use_tls = 0 + +default_nick = "FunUser" +default_user = "funuser" +default_real = "Fun Interactive Client" +network_pass = "" // Optional network password (not NickServ) + +// Optionally auto-join a channel after connect (empty string to skip) +auto_join = "#fun-lang" + +dt = DateTime() +proc = Process() + +// Initialize globals used by prompt/printing early so handlers can access them +current_chan = "" +current_nick = default_nick +// When set to 1, we skip the pre-prompt drain and immediately show the prompt +want_fast_prompt = 0 +// Small counter to skip a few network pump cycles right after commands +skip_pump_ticks = 0 + +// --- Timing knobs (tunable at runtime) --- +// How long to drain incoming lines before each prompt (ms). 0 => no drain. +pre_drain_ms = 0 +// Sleep between empty pumps during the pre-prompt drain (ms) +pre_drain_idle_ms = 20 +// After sending a line (/msg or plain), how long to briefly pump (ms) +send_pump_ms = 400 + +// --- Helpers --- +fun print_help() + print("Commands:") + print(" /help Show this help") + print(" /nick Change nickname") + print(" /join <#channel> Join a channel") + print(" /part [#channel] [msg] Leave a channel (current if omitted)") + print(" /msg Send PRIVMSG to channel or user") + print(" /me Send ACTION (/me) to target") + print(" /raw Send a raw IRC line") + print(" /names [#channel] Show names for a channel") + print(" /drainms Set pre-prompt drain milliseconds (0 = off)") + print(" /sendpump Set brief post-send pump ms") + print(" /showtimers Show current timing settings") + print(" /quit [message] Quit and exit") + print("") + print("Typing a line without '/' will send it to the current channel if set.") + +// Extract nick from a prefix like "nick!user@host" +fun prefix_nick(prefix) + p = to_string(prefix) + ex = find(p, "!") + if (ex >= 0) + return substr(p, 0, ex) + at = find(p, "@") + if (at >= 0) + return substr(p, 0, at) + return p + +// Internal helper: ensure params is an Array +fun _msg_params_safe(m) + p = m["params"] + if (typeof(p) == "Array") + return p + return [] + +// Render a parsed message in a readable way (defensive conversions) +fun render_msg(msg) + cmd = to_string(msg["command"]) + if (cmd == "PRIVMSG") + sender = prefix_nick(to_string(msg["prefix"])) + ps = _msg_params_safe(msg) + tgt_s = "" + if (len(ps) > 0) + tgt_s = to_string(ps[0]) + txt_s = to_string(msg["trailing"]) + if (len(tgt_s) > 0) + // Avoid join() to prevent any accidental shadowing; use concatenation + out = "[" + tgt_s + "] <" + sender + "> " + txt_s + print(out) + else + out = "<" + sender + "> " + txt_s + print(out) + return + if (cmd == "NOTICE") + sender = prefix_nick(to_string(msg["prefix"])) + ps = _msg_params_safe(msg) + // target (unused in print below, but keep for parity with PRIVMSG) + tgt_s = "" + if (len(ps) > 0) + tgt_s = to_string(ps[0]) + txt_s = to_string(msg["trailing"]) + out = "-" + sender + "- " + txt_s + print(out) + return + if (cmd == "PING") + // Handled by pump(); we don't spam output for PINGs + return + // Default: print raw line representation + // (Caller prints the original line; this is an extra if wanted.) + +// Build the prompt string reflecting current channel +fun _prompt_str() + // Include current local date/time with seconds in the prompt for debugging + ts = dt.format(dt.now_ms(), "%Y-%m-%d %H:%M:%S") + p = "[" + ts + "] irc" + if (len(current_chan) > 0) + p = p + "[" + current_chan + "]" + return p + "> " + +// Repaint the prompt immediately (used after commands/output) +fun _repaint_prompt() + // Force a fresh line, then print the prompt + print("") + echo(_prompt_str()) + +// Process incoming lines once; auto-PONG is done in IRCClient.pump via callback +fun on_line(line, msg) + // Print raw lines only for maximum robustness (avoids CALL runtime issues) + print(line) + // Update local state based on parsed message so prompt/channel reflect reality + cmd = to_string(msg["command"]) + ps = _msg_params_safe(msg) + // Track our own NICK changes + if (cmd == "NICK") + oldn = prefix_nick(to_string(msg["prefix"])) + newn = "" + if (len(ps) > 0) + newn = to_string(ps[0]) + else + newn = to_string(msg["trailing"]) // fallback + if (oldn == current_nick) + current_nick = newn + // Track JOIN/PART to set/unset current_chan when it's us + if (cmd == "JOIN") + who = prefix_nick(to_string(msg["prefix"])) + ch = "" + if (len(ps) > 0) + ch = to_string(ps[0]) + else + ch = to_string(msg["trailing"]) // e.g., ":#chan" + if (len(ch) > 0 && substr(ch, 0, 1) == ":") + ch = substr(ch, 1, len(ch) - 1) + if (who == current_nick) + current_chan = ch + print("Joined " + current_chan) + if (cmd == "PART") + who = prefix_nick(to_string(msg["prefix"])) + ch = "" + if (len(ps) > 0) + ch = to_string(ps[0]) + if (who == current_nick) + if (str_to_upper(ch) == str_to_upper(current_chan)) + current_chan = "" + // Track numerics that confirm join/names + if (cmd == "353" || cmd == "366") + // Try to find a channel name among params + ch = "" + i = 0 + while (i < len(ps)) + p = to_string(ps[i]) + if (len(p) > 0 && substr(p, 0, 1) == "#") + ch = p + break + i = i + 1 + if (len(ch) > 0) + // If we had not set current channel yet, set it now + if (len(current_chan) == 0) + current_chan = ch + print("Joined " + current_chan) + // After any incoming line, re-echo the prompt so it's visible again + // (helps after JOIN bursts) + print("") + echo(_prompt_str()) + +// Split one line into argv-like array by spaces, keeping trailing text after : +fun split_irc_cmd(line) + s = str_trim(to_string(line)) + if (len(s) == 0) + return [] + // If contains a space-colon, treat ":..." as a single arg + sp = find(s, " :") + if (sp >= 0) + left = substr(s, 0, sp) + right = substr(s, sp + 2, len(s) - sp - 2) + parts = str_split(left, " ") + push(parts, right) + return parts + return str_split(s, " ") + +// Join parts[start..end] with single spaces +fun join_rest(parts, start) + out = "" + i = to_number(start) + while (i < len(parts)) + if (len(out) == 0) + out = to_string(parts[i]) + else + out = out + " " + to_string(parts[i]) + i = i + 1 + return out + +// Briefly pump the IRC client to process server I/O (e.g., after sending) +fun pump_brief(client, ms) + duration = to_number(ms) + if (duration <= 0) + duration = 150 + t0 = dt.now_ms() + while (dt.now_ms() - t0 < duration) + n = client.pump(on_line) + if (n == 0) + dt.sleep_ms(30) + +// Try to read one line from stdin non-blockingly using an external shell. +// Returns map { "ok": 1/0, "line": string } +fun _stdin_try_read(timeout_ms) + ms = to_number(timeout_ms) + if (ms < 0) + ms = 0 + // Build bash command: read with timeout; on success print the line; exit 0 + // On timeout/EOF, exit non-zero with no output so we can distinguish. + // Use -lc to ensure non-interactive login shell features are available. + tsec = to_string(ms / 1000) + // Keep a minimum of 0.02s to avoid tight CPU spin + if (ms < 20) + tsec = "0.02" + cmd = "bash -lc 'read -t " + tsec + " -r line || exit 1; printf %s \"$line\"'" + r = proc.run(cmd) + if (r["code"] == 0) + return { "ok": 1, "line": to_string(r["out"]) } + return { "ok": 0, "line": "" } + +// --- Main --- +mode = " (plain)" +if (use_tls == 1) + mode = " (TLS via tunnel)" +print("Connecting to " + host + ":" + to_string(port) + mode) + +c = IRCClient(host, port, use_tls) +if (!c.connect()) + print("Failed to connect.") + exit(1) + +// Register +c.register(default_nick, default_user, default_real, network_pass) + +// Process initial greetings (and answer PINGs) +greet_start = dt.now_ms() +while (dt.now_ms() - greet_start < 2500) + c.pump(on_line) + +current_chan = "" +current_nick = default_nick +if (len(auto_join) > 0) + c.join(auto_join) + // Don't set current_chan yet; wait for server confirmation + dt.sleep_ms(400) + // Drain any immediate JOIN replies + c.pump(on_line) + +con = Console() +print_help() + +// Show initial prompt once +print("") +echo(_prompt_str()) + +while true + // 1) First, try to read any user input without blocking. This ensures + // commands execute immediately and are not delayed by network pumping. + polled = _stdin_try_read(60) + if (polled["ok"] == 1) + cmdline = str_trim(polled["line"]) + else + cmdline = "" + + if (len(cmdline) == 0) + // 2) No user input this tick — optionally defer pumping briefly right + // after a command to keep the prompt responsive. + if (skip_pump_ticks > 0) + skip_pump_ticks = skip_pump_ticks - 1 + // keep the prompt visible + echo("") + dt.sleep_ms(10) + continue + // Then pump server traffic once. If the + // underlying recv() blocks until data, keep the sleep short to + // avoid busy-waiting between bursts. + n = c.pump(on_line) + if (n == 0) + dt.sleep_ms(15) + // keep looping + continue + + if (substr(cmdline, 0, 1) == "/") + // Slash command + words = split_irc_cmd(substr(cmdline, 1, len(cmdline) - 1)) + if (len(words) == 0) + continue + op = str_to_lower(words[0]) + + if (op == "help") + print_help() + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + // Skip immediate network pumping this iteration so the prompt + // stays responsive right after command execution. + continue + + if (op == "nick") + if (len(words) < 2) + print("Usage: /nick ") + else + c.send_raw("NICK " + words[1]) + current_nick = words[1] + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + if (op == "join") + if (len(words) < 2) + print("Usage: /join <#channel>") + else + ch = words[1] + c.join(ch) + // Wait for server JOIN/353/366 to confirm; we'll update current_chan in on_line + print("Joining " + ch) + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + if (op == "part") + // /part [#chan] [msg] + ch = current_chan + reason = "" + if (len(words) >= 2) + ch = words[1] + if (len(words) >= 3) + reason = words[2] + if (len(ch) == 0) + print("Usage: /part [#channel] [message]") + else + c.part(ch, reason) + if (str_to_upper(ch) == str_to_upper(current_chan)) + current_chan = "" + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + if (op == "msg") + if (len(words) < 3) + print("Usage: /msg ") + else + tgt = words[1] + txt = join_rest(words, 2) + c.privmsg(tgt, txt) + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + if (op == "me") + if (len(words) < 3) + print("Usage: /me ") + else + tgt = words[1] + txt = join_rest(words, 2) + c.privmsg(tgt, "\x01ACTION " + txt + "\x01") + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + if (op == "raw") + if (len(words) < 2) + print("Usage: /raw ") + else + c.send_raw(join_rest(words, 1)) + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + if (op == "drainms") + if (len(words) < 2) + print("Usage: /drainms ") + else + pre_drain_ms = to_number(words[1]) + print("pre_drain_ms set to " + to_string(pre_drain_ms) + " ms") + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + if (op == "sendpump") + if (len(words) < 2) + print("Usage: /sendpump ") + else + send_pump_ms = to_number(words[1]) + print("send_pump_ms set to " + to_string(send_pump_ms) + " ms") + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + if (op == "showtimers") + print("Timers:") + print(" pre_drain_ms = " + to_string(pre_drain_ms)) + print(" pre_drain_idle_ms = " + to_string(pre_drain_idle_ms)) + print(" send_pump_ms = " + to_string(send_pump_ms)) + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + if (op == "names") + ch = current_chan + if (len(words) >= 2) + ch = words[1] + if (len(ch) == 0) + print("Usage: /names [#channel]") + else + c.send_raw("NAMES " + ch) + _repaint_prompt() + want_fast_prompt = 1 + continue + + if (op == "quit") + msg = "" + if (len(words) >= 2) + msg = words[1] + c.quit(msg) + dt.sleep_ms(200) + c.close() + print("Goodbye.") + break + + print("Unknown command. Type /help") + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + continue + + // Regular message: send to current channel if set + if (len(current_chan) == 0) + print("No current channel. Use /join <#channel> or /msg .") + _repaint_prompt() + else + c.privmsg(current_chan, cmdline) + // Local echo so you see what you sent immediately + print("[" + current_chan + "] <" + current_nick + "> " + cmdline) + // Repaint prompt after sending; server responses will print concurrently + _repaint_prompt() + want_fast_prompt = 1 + skip_pump_ticks = 3 + +// Exit program