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
This commit is contained in:
ed 2024-04-01 21:52:12 +00:00
parent 6574978321
commit 732254de88
3 changed files with 110 additions and 18 deletions

View File

@ -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

View File

@ -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()]

View File

@ -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()