487 lines
14 KiB
Standard ML
487 lines
14 KiB
Standard ML
#!/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 <net/irc.fun>
|
|
#include <strings.fun>
|
|
#include <io/console.fun>
|
|
#include <io/process.fun>
|
|
#include <utils/datetime.fun>
|
|
|
|
// --- 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=<local>, 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 <newnick> Change nickname")
|
|
print(" /join <#channel> Join a channel")
|
|
print(" /part [#channel] [msg] Leave a channel (current if omitted)")
|
|
print(" /msg <target> <text> Send PRIVMSG to channel or user")
|
|
print(" /me <target> <text> Send ACTION (/me) to target")
|
|
print(" /raw <line> Send a raw IRC line")
|
|
print(" /names [#channel] Show names for a channel")
|
|
print(" /drainms <ms> Set pre-prompt drain milliseconds (0 = off)")
|
|
print(" /sendpump <ms> 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 <newnick>")
|
|
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 <target> <text>")
|
|
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 <target> <text>")
|
|
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 <line>")
|
|
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 <milliseconds>")
|
|
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 <milliseconds>")
|
|
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 <nick> <text>.")
|
|
_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
|