fix recent messages by handling valueless and escaped irc tags

The recent-messages API now serializes empty IRCv3 tags as bare keys
(e.g. `emotes`) instead of `emotes=`, which threw a RangeError in the
tag parser and silently dropped every recent message. Parse tags per the
IRCv3 spec: treat bare and empty values as equivalent, split on the first
`=` so values may contain `=`, and unescape values in a single pass
(\: \s \ \r \n). Also guard user extraction against prefixes without
`!`, and isolate per-message parse failures in _handleIRCData so one bad
message can no longer abort an entire batch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-06-08 21:53:53 -07:00
parent 2b81f2cb31
commit c27a37d52f
4 changed files with 144 additions and 20 deletions

View file

@ -959,22 +959,25 @@ class IRCMessage {
// Obtain the index to break off the tags.
final tagAndIrcMessageDivider = whole.indexOf(' ');
// Get the tags substring and escape characters.
// IRC messages escape spaces with \s.
final tags = whole
.substring(1, tagAndIrcMessageDivider)
.replaceAll('\\s', ' ');
// Next, parse and map the tags.
// Parse the IRCv3 tags into a map. The tags section is everything between
// the leading '@' and the first space. We split on the *raw* string: a
// literal ';' is always a separator because escaped semicolons inside a
// value are encoded as `\:` (unescaped per-value below).
final mappedTags = <String, String>{};
final rawTags = whole.substring(1, tagAndIrcMessageDivider);
// Loop through each tag and store their key value pairs into the map.
for (final tag in tags.split(';')) {
// Skip if the tag has no value.
if (tag.endsWith('=')) continue;
for (final tag in rawTags.split(';')) {
// Split on the first '=' only — IRCv3 tag values may contain '='.
final equalsIndex = tag.indexOf('=');
final tagSplit = tag.split('=');
mappedTags[tagSplit[0]] = tagSplit[1];
// Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`)
// and an empty value (`key=`) are equivalent — live Twitch sends the
// latter, the recent-messages API the former.
if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue;
mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue(
tag.substring(equalsIndex + 1),
);
}
// Now we'll parse the message itself.
@ -990,9 +993,19 @@ class IRCMessage {
// If the username exists, set it.
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final String? user = splitMessage[0] == _twitchIrcServer
? mappedTags['login']
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
final prefix = splitMessage[0];
final String? user;
if (prefix == _twitchIrcServer) {
user = mappedTags['login'];
} else {
// Normal prefixes are `nick!user@host`. Fall back to the login tag (or
// null) if the expected `!` is absent so an unexpected prefix shape can
// never throw a RangeError and take down the whole message.
final bangIndex = prefix.indexOf('!');
user = bangIndex == -1
? mappedTags['login']
: prefix.substring(0, bangIndex);
}
// If there is an associated message, set it.
String? message;
@ -1153,6 +1166,50 @@ class IRCMessage {
);
}
/// Unescapes an IRCv3 message-tag value.
///
/// The spec defines these sequences: `\:` → `;`, `\s` → space, `\\` → `\`,
/// `\r` → CR, `\n` → LF. Any other `\x` collapses to `x`, and a lone trailing
/// backslash is dropped. Done in a single pass so sequences can't be
/// double-unescaped (e.g. `\\s` correctly yields `\s`, not a space).
static String _unescapeTagValue(String value) {
// Fast path: the vast majority of tag values contain no escape sequences.
if (!value.contains('\\')) return value;
final buffer = StringBuffer();
for (var i = 0; i < value.length; i++) {
if (value[i] != '\\') {
buffer.write(value[i]);
continue;
}
// A trailing backslash with nothing after it is dropped.
if (i + 1 == value.length) break;
switch (value[i + 1]) {
case ':':
buffer.write(';');
break;
case 's':
buffer.write(' ');
break;
case '\\':
buffer.write('\\');
break;
case 'r':
buffer.write('\r');
break;
case 'n':
buffer.write('\n');
break;
default:
buffer.write(value[i + 1]);
}
i++; // Skip the escaped character we just consumed.
}
return buffer.toString();
}
factory IRCMessage.createNotice({
required String message,
VoidCallback? actionCallback,

View file

@ -519,10 +519,19 @@ abstract class ChatStoreBase with Store {
for (final message in data.trimRight().split('\r\n')) {
// debugPrint('$message\n');
if (message.startsWith('@')) {
final parsedIRCMessage = IRCMessage.fromString(
message,
userLogin: auth.user.details?.login,
);
// Parse defensively: a single malformed message must never abort the
// loop, or one bad frame would drop every other message batched with
// it (e.g. an entire recent-messages backlog). Skip and keep going.
final IRCMessage parsedIRCMessage;
try {
parsedIRCMessage = IRCMessage.fromString(
message,
userLogin: auth.user.details?.login,
);
} catch (e) {
debugPrint('Failed to parse IRC message: $e');
continue;
}
if (parsedIRCMessage.user != null) {
chatDetailsStore.chatUsers.add(parsedIRCMessage.user!);

View file

@ -9,6 +9,19 @@ const basicPrivmsg =
'tmi-sent-ts=1234567890123;turbo=0;user-id=67890;user-type= '
':testuser!testuser@testuser.tmi.twitch.tv PRIVMSG #channel :Hello World';
/// Exercises the full IRCv3 tag escape set plus a value that itself contains
/// '='. Uses a raw string so the backslashes are literal wire bytes.
const messageWithEscapedTagChars =
r'@a-space=one\stwo;a-semi=left\:right;a-slash=a\\b;a-equals=k=v;'
r'a-double=x\\sy;display-name=EscapeAll;mod=0 '
r':escapeall!escapeall@escapeall.tmi.twitch.tv PRIVMSG #channel :hi';
/// A message whose prefix is not the usual `nick!user@host` form (no '!').
/// The parser should fall back to the login tag instead of throwing.
const messageNoBangPrefix =
'@login=fallbackuser;mod=0;id=nobang-1 '
':some.server.example PRIVMSG #channel :server style';
/// A chat message with emotes in the tags
/// Note: emote positions are 0-indexed byte positions in the message
/// "Kappa LUL" - Kappa is at 0-4, LUL is at 6-8
@ -138,6 +151,16 @@ const historicalMessage =
'tmi-sent-ts=1234567890123;turbo=0;user-id=12121;user-type= '
':histuser!histuser@histuser.tmi.twitch.tv PRIVMSG #channel :Historical message';
/// A historical message as serialized by the recent-messages API, which emits
/// empty tags as bare keys (e.g. `emotes`, `flags`) instead of the `key=` form
/// that live Twitch IRC uses.
const historicalMessageBareTags =
'@badges=raging-wolf-helm/1;emotes;first-msg=0;tmi-sent-ts=1234567890123;'
'flags;mod=0;returning-chatter=0;rm-received-ts=1234567890456;color=#1E90FF;'
'id=hist-bare-123;room-id=12345;historical=1;user-type;turbo=0;subscriber=0;'
'badge-info;display-name=BareUser;user-id=18181 '
':bareuser!bareuser@bareuser.tmi.twitch.tv PRIVMSG #channel :Bare tag message';
/// A message from a shared chat session (source room different from current)
const sharedChatMessage =
'@badge-info=;badges=;color=#00BFFF;display-name=SharedUser;emotes=;'

View file

@ -27,6 +27,27 @@ void main() {
expect(msg.message, 'test message');
});
test('unescapes the full IRCv3 tag escape set', () {
final msg = IRCMessage.fromString(messageWithEscapedTagChars);
expect(msg.tags['a-space'], 'one two');
expect(msg.tags['a-semi'], 'left;right');
expect(msg.tags['a-slash'], r'a\b');
// Only the first '=' splits key/value; later '=' stays in the value.
expect(msg.tags['a-equals'], 'k=v');
// `\\` consumes both backslashes; the following 's' is a literal letter,
// not a `\s` space escape (single-pass unescaping).
expect(msg.tags['a-double'], r'x\sy');
});
test('falls back to login tag when prefix has no "!"', () {
// An unexpected prefix shape must not throw and drop the message.
final msg = IRCMessage.fromString(messageNoBangPrefix);
expect(msg.user, 'fallbackuser');
expect(msg.message, 'server style');
});
test('handles message with no color tag', () {
final msg = IRCMessage.fromString(messageNoColor);
@ -223,6 +244,20 @@ void main() {
expect(msg.tags['historical'], '1');
});
test('parses historical message with bare (valueless) tags', () {
// The recent-messages API serializes empty tags as bare keys (e.g.
// `emotes`, `flags`) instead of `key=`. This previously threw a
// RangeError, silently dropping all recent messages.
final msg = IRCMessage.fromString(historicalMessageBareTags);
expect(msg.tags['historical'], '1');
expect(msg.tags['display-name'], 'BareUser');
expect(msg.message, 'Bare tag message');
// Bare tags should be absent from the map, not crash parsing.
expect(msg.tags.containsKey('emotes'), isFalse);
expect(msg.tags.containsKey('flags'), isFalse);
});
test('parses shared chat message with source room', () {
final msg = IRCMessage.fromString(sharedChatMessage);