From 732254de88425a0ae1e26d601c8d04cf59442948 Mon Sep 17 00:00:00 2001 From: ed Date: Mon, 1 Apr 2024 21:52:12 +0000 Subject: [PATCH] irc-bridge: rate-limit, ping, ctcp-version, show irc joins only shows joins/parts from the irc network in r0c; does not relay r0c joins/parts to irc adds support for rizon (which requires ctcp replies) and fixes connection-drop if r0c channels are idle also reduces the max latency before an incoming message from irc gets displayed, from 1s to .5s the /me command still does not relay to irc --- README.md | 3 ++ r0c/__main__.py | 7 ++- r0c/irc.py | 118 +++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 110 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6b3d0ce..395daf0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ retr0chat is the lightweight, no-dependencies, runs-anywhere solution for when l * tries to be irssi * zero dependencies on python 2.6, 2.7, 3.x * supports telnet, netcat, /dev/tcp, TLS clients +* is not an irc server, but can bridge to/from irc servers * [modem-aware](https://ocv.me/r0c-2400.webm); comfortable at 1200 bps * fallbacks for inhumane conditions * linemode @@ -46,6 +47,7 @@ technical: * history of sent messages (arrow-up/down) * bandwidth-conservative (push/pop lines instead of full redraws; scroll-regions) * fast enough; 600 clients @ 750 msgs/sec, or 1'000 cli @ 350 msg/s +* bridge several irc channels from several networks into one r0c channel ## windows clients @@ -150,6 +152,7 @@ try the following commands and hotkeys after connecting: * `/cy` enables colored nicknames * `/b3` (max cowbell) beeps on every message * `/v` or `ctrl-n` hides names and makes wordwrap more obvious; good for viewing a wall of text that somebody pasted +* `CTRL-L` or `/r` if rendering breaks ## other surprises diff --git a/r0c/__main__.py b/r0c/__main__.py index 42d5082..1cfa126 100644 --- a/r0c/__main__.py +++ b/r0c/__main__.py @@ -65,6 +65,8 @@ def optgen(ap, pwd): ac = ap.add_argument_group("irc-bridge") ac.add_argument("--ircn", metavar="TXT", type=u, action="append", help='connect to an irc server; TXT is: "netname,hostname,[+]port,nick[,username[,password]]" (if password contains "," then use ", " as separator)') ac.add_argument("--ircb", metavar="N,C,L", type=u, action="append", help="bridge irc-netname N, irc-channel #C with r0c-channel #L") + ac.add_argument("--i-rate", metavar="B,R", type=u, default="4,2", help="rate limit; burst of B messages, then R seconds between each") + ac.add_argument("--ctcp-ver", metavar="S", type=u, default="r0c v%s" % (S_VERSION), help="reply to CTCP VERSION") ac = ap.add_argument_group("ux") ac.add_argument("--no-all", action="store_true", help="default-disable @all / @everyone") @@ -207,6 +209,7 @@ class Core(object): ar = self.ar = rap(argv, pwd) # type: argparse.Namespace ar.ircn = ar.ircn or [] ar.ircb = ar.ircb or [] + ar.i_rate_b, ar.i_rate_s = [float(x) for x in ar.i_rate.split(",")] ar.proxy = ar.proxy.split(",") if "127.0.0.1" in ar.proxy or "::1" in ar.proxy: t = "\033[33mWARNING: you have localhost in --proxy, you probably want --ara too\033[0m" @@ -411,6 +414,8 @@ printf '%s\\n' GK . . . . r0c.int . | openssl req -newkey rsa:2048 -sha256 -keyo for iface in self.servers: srvs[iface.srv_sck] = iface + t_fast = 0.5 if self.ar.ircn else 1 + sn = -1 sc = {} slow = {} # sck:cli @@ -435,7 +440,7 @@ printf '%s\\n' GK . . . . r0c.int . | openssl req -newkey rsa:2048 -sha256 -keyo sc[c.sck] = c - timeout = 0.2 if slow else 1 if fast else 69 + timeout = 0.2 if slow else t_fast if fast else 69 want_tx = [s for s, c in fast.items() if c.writable()] want_rx = [s for s, c in sc.items() if c.readable()] diff --git a/r0c/irc.py b/r0c/irc.py index 4e30e10..7bbe64f 100644 --- a/r0c/irc.py +++ b/r0c/irc.py @@ -1,12 +1,11 @@ # coding: utf-8 from __future__ import print_function from .__init__ import TYPE_CHECKING -from . import chat as Chat from . import util as Util -from . import user as User import time import socket +import threading print = Util.print whoops = Util.whoops @@ -32,15 +31,53 @@ class IRC_Net(object): self.backlog = b"" self.nick_suf = 0 self.generation = 0 + self.cnick = "" self.chans = {} # type: dict[str, IRC_Chan] + self.msg_q = [] + self.hist = [] + self.mutex = threading.Lock() - def say(self, msg): + def tx(self, msg): + if self.ar.dbg_irc: + for ln in msg.split("\r\n"): + print("\033[90mirc <%s [%s]\033[0m" % (self.host, ln)) try: self.sck.sendall((msg + "\r\n").encode("utf-8", "replace")) except: t = "XXX lost connection to irc during write: %s" print(t % (msg,)) + def say(self, msg): + with self.mutex: + if self._enqueue_msg(msg): + return + self.tx(msg) + + def _say(self, msg): + if not self._enqueue_msg(msg): + self.tx(msg) + + def _enqueue_msg(self, msg): + if self._is_rate_limited(): + self.msg_q.append(msg) + return True + self._tick_ratelimit() + + def _is_rate_limited(self): + if len(self.hist) < self.ar.i_rate_b: + return False + + now = time.time() + return ( + now - self.hist[0] < self.ar.i_rate_s * self.ar.i_rate_b + and now - self.hist[-1] < self.ar.i_rate_s + ) + + def _tick_ratelimit(self): + self.hist.append(time.time()) + while len(self.hist) > self.ar.i_rate_b: + self.hist.pop(0) + def addchan(self, irc_cname, r0c_cname): # type: (str, str) -> None self.world.join_pub_chan(None, r0c_cname) @@ -59,12 +96,18 @@ class IRC_Net(object): Util.Daemon(self._connect, "irc_c_%s" % (self.host,)) def _connect(self): + n = 0 while True: try: self._connect_once() + if n: + t = "finally connected to irc<%s> after %d failed attempts (nice)" + print(t % (self.host, n)) return except Exception as ex: - print("XXX connecting irc<%s> failed: %s" % (self.host, ex)) + n += 1 + t = "XXX connecting irc<%s> failed (attempt %d): %s" + print(t % (self.host, n, ex)) time.sleep(5) def _connect_once(self): @@ -73,7 +116,9 @@ class IRC_Net(object): print(t % (self.host,)) return - self.generation += 1 + with self.mutex: + self.generation += 1 + sck = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sck.connect((self.host, int(self.port))) if self.tls: @@ -85,23 +130,31 @@ class IRC_Net(object): self.sck = sck self.nick_suf = 0 + self.cnick = self.nick Util.Daemon(self._main, "ircm_%s" % (self.host,)) Util.Daemon(self._recv, "ircr_%s" % (self.host,)) def _main(self): generation = self.generation t = "NICK {0}\r\nUSER {0} {0} {1} :{0}" - self.say(t.format(self.nick, self.host)) + self.tx(t.format(self.nick, self.host)) while True: time.sleep(1) - if generation != self.generation: - break + with self.mutex: + if generation != self.generation: + break - for ch in self.chans.values(): - if ch.joined: - continue + while self.msg_q and not self._is_rate_limited(): + self._tick_ratelimit() + self.tx(self.msg_q.pop(0)) - self.say("JOIN #%s" % (ch.irc_cname,)) + for ch in self.chans.values(): + if ch.joined: + continue + + t = "JOIN #%s" % (ch.irc_cname,) + if t not in self.msg_q: + self._say(t) def _recv(self): sck = self.sck @@ -110,7 +163,9 @@ class IRC_Net(object): bmsg = sck.recv(4096) if not bmsg: print("XXX lost connection to irc") - self.generation += 1 + with self.mutex: + self.generation += 1 + time.sleep(2) Util.Daemon(self._connect, "irc_re_%s" % (self.host,)) return @@ -132,14 +187,43 @@ class IRC_Net(object): def handle_msg(self, msg): if self.ar.dbg_irc: - print("\033[90mirc<%s> [%s]\033[0m" % (self.host, msg)) + print("\033[90mirc %s> [%s]\033[0m" % (self.host, msg)) mw = msg.split(" ", 3) + + if mw[0] == "PING": + self.tx("PO" + msg[2:]) + return + + if len(mw) < 3: + return + + if mw[1] in ("JOIN", "PART"): + nick = mw[0].split("!")[0].split(":")[-1] + ch_name = mw[2][1:] + if ch_name not in self.chans or nick == self.cnick: + return + + print("irc<%s #%s> %s [%s]" % (self.host, ch_name, mw[1], nick)) + try: + nch = self.world.get_pub_chan(self.chans[ch_name].r0c_cname) + t = u"irc: \033[1;32m%s\033[22m has %sed" % (nick, mw[1].lower()) + if len(mw) > 3: + t += " (%s)" % (mw[3][1:]) + + self.world.send_chan_msg(u"--", nch, t, False) + except: + whoops() + if len(mw) < 4: return if mw[1] == "PRIVMSG": nick = mw[0].split("!")[0].split(":")[-1] + if mw[3] == ":\x01VERSION\x01": # ctcp required by rizon + self.say("NOTICE %s :\x01VERSION %s\x01" % (nick, self.ar.ctcp_ver)) + return + ch_name = mw[2][1:] if ch_name not in self.chans: t = "XXX msg from chan [%s] not in %s ???" @@ -178,13 +262,13 @@ class IRC_Net(object): self.destroy() return - t = "NICK {0}{1}\r\nUSER {0} {0} {2} :{0}" - self.say(t.format(self.nick, self.nick_suf, self.host)) + self.cnick = "%s%s" % (self.nick, self.nick_suf) + self.tx("NICK {0}\r\nUSER {0} {0} {1} :{0}".format(self.cnick, self.host)) return if sc == "464": if self.pwd: - self.say("PASS %s:%s" % (self.uname, self.pwd)) + self.tx("PASS %s:%s" % (self.uname, self.pwd)) else: print("XXX irc server requires a password to connect") self.destroy()