Skip to content

Commit a1ef679

Browse files
committed
Fix access flags checks and message-tags support
- Access flag accounts are normalized to all-lowercase in db.py so ensure that we normalize when checking them as well - We requested message-tags but oyoyo had no support for parsing them and it broke everything if enabled. Fix that, and also enable account-tag for another means of checking accounts more reliably
1 parent 97acf3a commit a1ef679

File tree

8 files changed

+194
-68
lines changed

8 files changed

+194
-68
lines changed

oyoyo/client.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ def connect(self):
296296
buffer = data.pop()
297297

298298
for el in data:
299-
prefix, command, args = parse_raw_irc_command(el)
299+
tags, prefix, command, args = parse_raw_irc_command(el)
300300

301301
try:
302302
enc = "utf8"
@@ -306,16 +306,18 @@ def connect(self):
306306
fargs = [arg.decode(enc) for arg in args if isinstance(arg,bytes)]
307307

308308
try:
309-
largs = list(args)
310309
if prefix is not None:
311310
prefix = prefix.decode(enc)
312311
self.stream_handler("<--- receive {0} {1} ({2})".format(prefix, command, ", ".join(fargs)), level="debug")
313-
# for i,arg in enumerate(largs):
314-
# if arg is not None: largs[i] = arg.decode(enc)
312+
if tags:
313+
# TODO: this sucks for two reasons: 1) value is unescaped and may contain semicolons/newlines,
314+
# 2) we're doing str.format unconditionally here (and above) when it should be passed the logging layer as args
315+
# so that we don't waste CPU cycles doing string formatting when the message is never going to be displayed
316+
self.stream_handler(" @{0}".format(";".join("{0}={1}".format(k, v) if v else k for k, v in tags.items())), level="debug")
315317
if command in self.command_handler:
316-
self.command_handler[command](self, prefix,*fargs)
318+
self.command_handler[command](self, prefix, *fargs, tags=tags)
317319
elif "" in self.command_handler:
318-
self.command_handler[""](self, prefix, command, *fargs)
320+
self.command_handler[""](self, prefix, command, *fargs, tags=tags)
319321
except Exception as e:
320322
sys.stderr.write(traceback.format_exc())
321323
raise e # ?

oyoyo/parse.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,36 +25,83 @@ def parse_raw_irc_command(element):
2525
of (prefix, command, args).
2626
The following is a psuedo BNF of the input text:
2727
28-
<message> ::= [':' <prefix> <SPACE> ] <command> <params> <crlf>
28+
<message> ::= [ '@' <tags> <SPACE> ] [ ':' <prefix> <SPACE> ] <command> <params> <crlf>
29+
<tags> ::= <tag> { ';' <tag> }
30+
<tag> ::= <key> [ '=' <value> ]
2931
<prefix> ::= <servername> | <nick> [ '!' <user> ] [ '@' <host> ]
3032
<command> ::= <letter> { <letter> } | <number> <number> <number>
3133
<SPACE> ::= ' ' { ' ' }
3234
<params> ::= <SPACE> [ ':' <trailing> | <middle> <params> ]
35+
<key> ::= [ '+' ] [ <vendor> '/' ] <key_name>
3336
3437
<middle> ::= <Any *non-empty* sequence of octets not including SPACE
3538
or NUL or CR or LF, the first of which may not be ':'>
3639
<trailing> ::= <Any, possibly *empty*, sequence of octets not including
3740
NUL or CR or LF>
3841
42+
<key> ::= <Any *non-empty* sequence of ASCII letters, digits, or hyphens>
43+
<vendor> ::= <hostname>
44+
<value> ::= <Any, possibly *empty*, sequence of utf-8 characters except
45+
NUL, CR, LF, semicolon (';'), and SPACE>
46+
3947
<crlf> ::= CR LF
4048
"""
4149
parts = element.strip().split(bytes(" ", "utf_8"))
42-
if parts[0].startswith(bytes(':', 'utf_8')):
43-
prefix = parts[0][1:]
44-
command = parts[1]
45-
args = parts[2:]
50+
off = 0
51+
tags = {}
52+
if parts[0].startswith(bytes('@', "utf_8")):
53+
off = 1
54+
tags_str = parts[0][1:].split(bytes(';', "utf_8"))
55+
for tag in tags_str:
56+
tag_parts = tag.split(bytes('=', "utf_8"), maxsplit=1)
57+
if len(tag_parts) == 2 and len(tag_parts[1]) > 0:
58+
v = []
59+
esc = False
60+
for c in tag_parts[1].decode("utf-8"):
61+
match (esc, c):
62+
case (True, ':'):
63+
v.append(';')
64+
esc = False
65+
case (True, 's'):
66+
v.append(' ')
67+
esc = False
68+
case (True, '\\'):
69+
v.append('\\')
70+
esc = False
71+
case (True, 'r'):
72+
v.append('\r')
73+
esc = False
74+
case (True, 'n'):
75+
v.append('\n')
76+
esc = False
77+
case (True, _):
78+
v.append(c)
79+
esc = False
80+
case (False, '\\'):
81+
esc = True
82+
case (False, _):
83+
v.append(c)
84+
tags[tag_parts[0].decode("utf-8")] = "".join(v)
85+
else:
86+
tags[tag_parts[0].decode("utf-8")] = None
87+
88+
if parts[off].startswith(bytes(':', 'utf_8')):
89+
prefix = parts[off][1:]
90+
command = parts[off+1]
91+
args = parts[off+2:]
4692
else:
4793
prefix = None
48-
command = parts[0]
49-
args = parts[1:]
94+
command = parts[off]
95+
args = parts[off+1:]
5096

5197
if command.isdigit():
5298
try:
5399
command = numeric_events[command]
54100
except KeyError:
55101
pass
56102
command = command.lower()
57-
if isinstance(command, bytes): command = command.decode("utf_8")
103+
if isinstance(command, bytes):
104+
command = command.decode("utf_8")
58105

59106
if args[0].startswith(bytes(':', 'utf_8')):
60107
args = [bytes(" ", "utf_8").join(args)[1:]]
@@ -64,7 +111,7 @@ def parse_raw_irc_command(element):
64111
args = args[:idx] + [bytes(" ", 'utf_8').join(args[idx:])[1:]]
65112
break
66113

67-
return (prefix, command, args)
114+
return tags, prefix, command, args
68115

69116

70117
def parse_nick(name):

src/handler.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from __future__ import annotations
22

33
import base64
4+
import functools
45
import threading
56
import subprocess
67
import platform
78
import time
8-
import functools
99
import logging
1010
import sys
1111
from typing import Optional
@@ -16,22 +16,26 @@
1616
from src.functions import get_participants, get_all_roles, match_role
1717
from src.dispatcher import MessageDispatcher
1818
from src.decorators import handle_error, command, hook
19-
from src.context import Features
19+
from src.context import Features, NotLoggedIn
2020
from src.users import User
2121
from src.events import Event, EventListener
2222
from src.transport.irc import get_services
2323
from src.channels import Channel
2424

2525
@handle_error
26-
def on_privmsg(cli, rawnick, chan, msg, *, notice=False):
26+
def on_privmsg(cli, rawnick, chan, msg, *, notice=False, tags=None):
2727
if notice and "!" not in rawnick or not rawnick: # server notice; we don't care about those
2828
return
2929

3030
_ignore_locals_ = False
3131
if config.Main.get("telemetry.errors.user_data_level") == 0 or config.Main.get("telemetry.errors.channel_data_level") == 0:
3232
_ignore_locals_ = True # don't expose in tb if we're trying to anonymize stuff
3333

34+
if tags is None:
35+
tags = {}
36+
3437
user = users.get(rawnick, allow_none=True)
38+
account_tag = tags.get("account", NotLoggedIn)
3539

3640
ch = chan.lstrip("".join(Features["PREFIX"]))
3741

@@ -43,6 +47,13 @@ def on_privmsg(cli, rawnick, chan, msg, *, notice=False):
4347
if user is None or target is None:
4448
return
4549

50+
if Features.account_tag and user.account != account_tag:
51+
old_account = user.account
52+
old_user = user
53+
user.account = account_tag
54+
user = users.get(user.nick, user.ident, user.host, account_tag)
55+
Event("account_change", {}, old=old_user).dispatch(user, old_account)
56+
4657
wrapper = MessageDispatcher(user, target)
4758

4859
if wrapper.public and config.Main.get("transports[0].user.ignore.hidden") and not chan.startswith(tuple(Features["CHANTYPES"])):
@@ -194,9 +205,9 @@ def parse_and_dispatch(wrapper: MessageDispatcher,
194205
if phase == cur_phase: # don't call any more commands if one we just called executed a phase transition
195206
fn.caller(dispatch, message)
196207

197-
def unhandled(cli, prefix, cmd, *args):
208+
def unhandled(cli, prefix, cmd, *args, tags):
198209
for fn in decorators.HOOKS.get(cmd, []):
199-
fn.caller(cli, prefix, *args)
210+
fn.caller(cli, prefix, *args, tags=tags)
200211

201212
def ping_server(cli: IRCClient):
202213
cli.send("PING :{0}".format(time.time()))
@@ -206,7 +217,7 @@ def latency(wrapper, message):
206217
ping_server(wrapper.client)
207218

208219
@hook("pong", hookid=300)
209-
def latency_pong(cli, server, target, ts):
220+
def latency_pong(cli, server, target, ts, *, tags):
210221
lat = round(time.time() - float(ts), 3)
211222
wrapper.reply(messages["latency"].format(lat))
212223
hook.unhook(300)
@@ -235,7 +246,7 @@ def connect_callback(cli: IRCClient):
235246

236247
@hook("endofmotd", hookid=294)
237248
@hook("nomotd", hookid=294)
238-
def prepare_stuff(cli: IRCClient, prefix: str, *args: str):
249+
def prepare_stuff(cli: IRCClient, prefix: str, *args, tags):
239250
logger.info("Received end of MOTD from {0}", prefix)
240251

241252
# This callback only sets up event listeners
@@ -285,7 +296,7 @@ def setup_handler(evt, target: User | Channel):
285296
who_end = EventListener(setup_handler)
286297
who_end.install("who_end")
287298

288-
def mustregain(cli: IRCClient, server, bot_nick, nick, msg):
299+
def mustregain(cli: IRCClient, server, bot_nick, nick, msg, *, tags):
289300
nonlocal regaincount
290301

291302
config_nick = config.Main.get("transports[0].user.nick")
@@ -303,7 +314,7 @@ def mustregain(cli: IRCClient, server, bot_nick, nick, msg):
303314
regaincount += 1
304315
users.Bot.change_nick(config_nick)
305316

306-
def mustrelease(cli: IRCClient, server, bot_nick, nick, msg):
317+
def mustrelease(cli: IRCClient, server, bot_nick, nick, msg, *, tags):
307318
nonlocal releasecount
308319

309320
config_nick = config.Main.get("transports[0].user.nick")
@@ -331,7 +342,14 @@ def must_use_temp_nick(cli, *etc):
331342
if services.supports_regain() or services.supports_ghost():
332343
hook("nicknameinuse", hookid=241)(mustregain)
333344

334-
request_caps = {"account-notify", "chghost", "extended-join", "multi-prefix"}
345+
request_caps = {
346+
"account-notify",
347+
"account-tag",
348+
"chghost",
349+
"extended-join",
350+
"message-tags",
351+
"multi-prefix",
352+
}
335353

336354
if config.Main.get("transports[0].authentication.services.use_sasl"):
337355
request_caps.add("sasl")
@@ -341,7 +359,7 @@ def must_use_temp_nick(cli, *etc):
341359
selected_sasl = None
342360

343361
@hook("cap")
344-
def on_cap(cli: IRCClient, svr, mynick, cmd: str, *caps: str):
362+
def on_cap(cli: IRCClient, svr, mynick, cmd: str, *caps: str, tags):
345363
nonlocal supported_sasl, selected_sasl
346364
# caps is a star because we might receive multiline in LS
347365
if cmd == "LS":
@@ -422,7 +440,7 @@ def on_cap(cli: IRCClient, svr, mynick, cmd: str, *caps: str):
422440

423441
if config.Main.get("transports[0].authentication.services.use_sasl"):
424442
@hook("authenticate")
425-
def auth_plus(cli: IRCClient, _, plus):
443+
def auth_plus(cli: IRCClient, _, plus, *, tags):
426444
username: str = config.Main.get("transports[0].authentication.services.username")
427445
if not username:
428446
username = config.Main.get("transports[0].user.nick")
@@ -443,14 +461,14 @@ def auth_plus(cli: IRCClient, _, plus):
443461
cli.send("AUTHENTICATE " + auth_token, log="AUTHENTICATE [redacted]")
444462

445463
@hook("saslsuccess")
446-
def on_successful_auth(cli: IRCClient, *_):
464+
def on_successful_auth(cli: IRCClient, *args, tags):
447465
Features["sasl"] = selected_sasl
448466
cli.send("CAP END")
449467

450468
@hook("saslfail")
451469
@hook("sasltoolong")
452470
@hook("saslaborted")
453-
def on_failure_auth(cli: IRCClient, *_):
471+
def on_failure_auth(cli: IRCClient, *args, tags):
454472
nonlocal selected_sasl
455473
if selected_sasl == "EXTERNAL" and (supported_sasl is None or "PLAIN" in supported_sasl):
456474
# EXTERNAL failed, retry with PLAIN as we may not have set up the client cert yet

0 commit comments

Comments
 (0)