diff --git a/CMakeLists.txt b/CMakeLists.txt index 521a616..2f2baaf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.10) -project(fun VERSION 0.41.10 LANGUAGES C) +project(fun VERSION 0.41.11 LANGUAGES C) set(CMAKE_C_STANDARD 99) set(CMAKE_C_STANDARD_REQUIRED ON) diff --git a/examples/net/irc_channel_demo.fun b/examples/net/irc_channel_demo.fun new file mode 100644 index 0000000..8cf7f01 --- /dev/null +++ b/examples/net/irc_channel_demo.fun @@ -0,0 +1,80 @@ +#!/usr/bin/env fun + +/* + * IRC channel demo using lib/net/irc.fun + * + * Connects to an IRC server, registers, joins #funlang, sends a message to + * the channel, reads a bit (responding to PINGs), then quits. + * + * TLS via local tunnel: + * - Point host to 127.0.0.1 and port to your local TLS tunnel. + * - Set use_tls = 1 (the library still uses plain TCP to the local tunnel). + */ + +#include +#include +#include +#include + +// --- Configuration --- +// freenode (plain): host="chat.freenode.net", port=6667, use_tls=0 +// For TLS via a local tunnel to remote 6697: host="127.0.0.1", port=, use_tls=1 +host = "chat.freenode.net" +port = 6667 +use_tls = 0 + +nick = "FunChannelBot" // Pick an unused nickname +user = "funchan" +real = "Hello superbrain! Fun IRC Channel Demo! Kisses hanez!" +pass = "" // Optional network password (not NickServ) +channel = "#fun-lang" // Requested channel + +// Date/time helper +dt = DateTime() + +// Helper: read, answer PINGs, and print lines +fun handle_lines(c) + lines = c.read_lines() + i = 0 + while (i < len(lines)) + line = lines[i] + msg = c.parse_message(line) + if (msg["command"] == "PING") + t = msg["trailing"] + if (len(t) == 0 && len(msg["params"]) > 0) + t = msg["params"][0] + c.pong(t) + print(line) + i = i + 1 + +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) + +c.register(nick, user, real, pass) + +// Process greetings and auto-respond to PINGs +start = dt.now_ms() +while (dt.now_ms() - start < 3000) + handle_lines(c) + +// Join #funlang and speak +c.join(channel) +dt.sleep_ms(500) +c.privmsg(channel, real) + +// Read a bit more to display echoes/traffic +start2 = dt.now_ms() +while (dt.now_ms() - start2 < 2000) + handle_lines(c) + +c.quit("bye") +dt.sleep_ms(200) +c.close() + +print("Done.") diff --git a/examples/net/irc_minimal.fun b/examples/net/irc_minimal.fun new file mode 100644 index 0000000..088b52b --- /dev/null +++ b/examples/net/irc_minimal.fun @@ -0,0 +1,84 @@ +#!/usr/bin/env fun + +/* + * Minimal example using lib/net/irc.fun + * + * This demo connects to an IRC server, registers, joins a channel, sends one + * message, reads for a short while (responding to PINGs), then quits. + * + * TLS usage via local tunnel (recommended): + * 1) Install stunnel and create a client service (example in repo docs). + * 2) Run this script pointing to localhost:, use_tls=1. + */ + +#include +#include +#include +#include + +// --- Configuration --- +// freenode (plain): host="chat.freenode.net", port=6667, use_tls=0 +// For TLS via a local tunnel to remote 6697: host="127.0.0.1", port=, use_tls=1 +host = "chat.freenode.net" +port = 6667 +use_tls = 0 + +nick = "FunDemoBot" // Pick an unused nickname +user = "fundemo" +real = "Fun Demo Bot" +pass = "" // Optional network password (not NickServ) +channel = "#funlang" // Use a channel you may write to +target_nick = "hanez" // For a test private message + +// Date/time helper +dt = DateTime() + +// Helper: read, answer PINGs, and print lines +fun handle_lines(c) + lines = c.read_lines() + i = 0 + while (i < len(lines)) + line = lines[i] + msg = c.parse_message(line) + if (msg["command"] == "PING") + t = msg["trailing"] + if (len(t) == 0 && len(msg["params"]) > 0) + t = msg["params"][0] + c.pong(t) + print(line) + i = i + 1 + +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) + +c.register(nick, user, real, pass) + +// Process greetings and auto-respond to PINGs +start = dt.now_ms() +while (dt.now_ms() - start < 3000) + handle_lines(c) + +// Join and speak +c.join(channel) +dt.sleep_ms(500) +c.privmsg(channel, "Hello from Fun!") + +// Send a test message to a nick as well +c.privmsg(target_nick, "Hi from Fun example script!") + +// Read a bit more +start2 = dt.now_ms() +while (dt.now_ms() - start2 < 2000) + handle_lines(c) + +c.quit("bye") +dt.sleep_ms(200) +c.close() + +print("Done.") diff --git a/lib/net/irc.fun b/lib/net/irc.fun new file mode 100644 index 0000000..dcae802 --- /dev/null +++ b/lib/net/irc.fun @@ -0,0 +1,208 @@ +/* + * Basic IRC client library for Fun + * + * Implements a class-based client for the IRC protocol suitable for simple + * bots or message senders. Designed to work over plain TCP. For TLS, point + * the client at a local TLS tunnel (e.g., stunnel) that connects to the + * remote IRC TLS endpoint. + */ + +#include +#include + +class IRCClient(string host, number port, number use_tls) + + fun _construct(this, host, port, use_tls) + this.host = to_string(host) + this.port = to_number(port) + this.use_tls = to_number(use_tls) // 0 = plain, 1 = TLS via local tunnel + this.nick = "" + this.user = "" + this.realname = "" + this.password = "" + this.connected = 0 + this._buf = "" // line buffer for recv() + + // Under current stdlib, only TcpClient is available. For TLS, connect to + // a local tunnel (e.g., stunnel) so we still use plain TCP here. + this.cli = TcpClient() + + fun connect(this) + ok = this.cli.connect(this.host, this.port) + if (ok) + this.connected = 1 + else + this.connected = 0 + return ok + + fun is_connected(this) + return this.connected == 1 && this.cli.is_connected() + + fun close(this) + if (this.cli != nil) + this.cli.close() + this.connected = 0 + return 1 + + // Basic RFC 1459 registration; password optional (PASS before NICK/USER) + fun register(this, nick, user, realname, password) + this.nick = to_string(nick) + this.user = to_string(user) + this.realname = to_string(realname) + this.password = to_string(password) + + if (len(this.password) > 0) + this.send_raw("PASS " + this.password) + + this.send_raw("NICK " + this.nick) + // USER : + this.send_raw("USER " + this.user + " 0 * :" + this.realname) + return 1 + + // Send raw IRC line (no trailing CRLF required by the caller) + fun send_raw(this, line) + if (!this.is_connected()) + return -1 + // Ensure CRLF termination + msg = to_string(line) + // Avoid dependency on str_ends_with; do it manually + need_crlf = 1 + if (len(msg) >= 2) + tail = substr(msg, len(msg) - 2, 2) + if (tail == "\r\n") + need_crlf = 0 + if (need_crlf == 1) + msg = msg + "\r\n" + return this.cli.send(msg) + + fun join(this, channel) + return this.send_raw("JOIN " + to_string(channel)) + + fun part(this, channel, msg) + ch = to_string(channel) + m = to_string(msg) + if (len(m) > 0) + return this.send_raw("PART " + ch + " :" + m) + return this.send_raw("PART " + ch) + + fun privmsg(this, target, text) + t = to_string(text) + return this.send_raw("PRIVMSG " + to_string(target) + " :" + t) + + fun notice(this, target, text) + return this.send_raw("NOTICE " + to_string(target) + " :" + to_string(text)) + + fun pong(this, token) + return this.send_raw("PONG :" + to_string(token)) + + fun quit(this, message) + m = to_string(message) + if (len(m) > 0) + this.send_raw("QUIT :" + m) + else + this.send_raw("QUIT") + return 1 + + // --- Reading and parsing --- + + // Read any available data and emit complete IRC lines (without CRLF) + fun read_lines(this) + if (!this.is_connected()) + return [] + // Read a chunk (tune size as needed) + chunk = this.cli.recv(4096) + if (len(chunk) == 0) + return [] + + this._buf = this._buf + chunk + out = [] + // Split on CRLF (IRC standard), also tolerate lone LF + while true + crlf = find(this._buf, "\r\n") + nl = find(this._buf, "\n") + sep = -1 + seplen = 0 + if (crlf >= 0) + sep = crlf + seplen = 2 + else + if (nl >= 0) + sep = nl + seplen = 1 + + if (sep < 0) + break + + line = substr(this._buf, 0, sep) + this._buf = substr(this._buf, sep + seplen, len(this._buf) - sep - seplen) + // Strip any trailing CR or LF just in case + line = str_trim(line) + if (len(line) > 0) + push(out, line) + + return out + + // Simple IRC message parser -> Map { prefix, command, params (Array), trailing } + fun parse_message(this, line) + s = to_string(line) + n = len(s) + + prefix = "" + if (n > 0 && substr(s, 0, 1) == ":") + sp = find(s, " ") + if (sp > 1) + prefix = substr(s, 1, sp - 1) + s = substr(s, sp + 1, len(s) - sp - 1) + + // Command up to first space or EOL + sp2 = find(s, " ") + command = "" + rest = "" + if (sp2 >= 0) + command = substr(s, 0, sp2) + rest = str_trim(substr(s, sp2 + 1, len(s) - sp2 - 1)) + else + command = s + rest = "" + + // Params until a " :trailing" token; trailing consumes remainder + params = [] + trailing = "" + while len(rest) > 0 + if (substr(rest, 0, 1) == ":") + trailing = substr(rest, 1, len(rest) - 1) + break + sp = find(rest, " ") + if (sp < 0) + push(params, rest) + rest = "" + else + push(params, substr(rest, 0, sp)) + rest = str_trim(substr(rest, sp + 1, len(rest) - sp - 1)) + + return { + "prefix": prefix, + "command": str_to_upper(command), + "params": params, + "trailing": trailing + } + + // Convenience: process incoming messages by calling a callback for each line + // callback(line, msgMap) where msgMap is output of parse_message + fun pump(this, callback) + lines = this.read_lines() + i = 0 + while (i < len(lines)) + ln = lines[i] + msg = this.parse_message(ln) + // Auto-reply to PINGs + if (msg["command"] == "PING") + t = msg["trailing"] + if (len(t) == 0 && len(msg["params"]) > 0) + t = msg["params"][0] + this.pong(t) + // Invoke user callback + if (typeof(callback) == "Function") + callback(ln, msg) + i = i + 1 + return len(lines)