@@ -52,6 +52,16 @@ public class SimpleIrcClient {
5252 private boolean connected = false ;
5353 private volatile boolean shuttingDown = false ;
5454
55+ String currentTagTime ; // package-private: accessed by TestableIrcClient subclass
56+ String currentTagBatch ; // package-private: accessed by TestableIrcClient subclass
57+
58+ private final Map <String , List <IrcEvent >> activeBatches = new HashMap <>();
59+ private final Map <String , String > activeBatchChannels = new HashMap <>();
60+
61+ boolean capHistorySupported = false ; // package-private: accessed by TestableIrcClient subclass
62+ private boolean capEndSent = false ;
63+ private final Set <String > advertisedCaps = new HashSet <>();
64+
5565 public SimpleIrcClient server (String host , int port , boolean secure ) {
5666 this .host = host ;
5767 this .port = port ;
@@ -86,8 +96,12 @@ public void connect() {
8696 if (password != null && !password .isEmpty ()) {
8797 sendRawLine ("PASS " + password );
8898 }
99+ advertisedCaps .clear ();
100+ capEndSent = false ;
101+ capHistorySupported = false ;
89102 sendRawLine ("NICK " + nick );
90103 sendRawLine ("USER " + username + " 0 * :" + realName );
104+ sendRawLine ("CAP LS 302" );
91105
92106 connected = true ;
93107 fireEvent (new IrcEvent (IrcEvent .Type .CONNECT , null , null , null , null ));
@@ -153,6 +167,8 @@ public void disconnect(String reason) {
153167 } catch (IOException ignored ) {
154168 }
155169 } finally {
170+ activeBatches .clear ();
171+ activeBatchChannels .clear ();
156172 connected = false ;
157173 fireEvent (new IrcEvent (IrcEvent .Type .DISCONNECT , null , null , null , null ));
158174 }
@@ -210,6 +226,10 @@ public void setNick(String newNick) {
210226 }
211227 }
212228
229+ void setNickDirect (String nick ) {
230+ this .nick = nick ;
231+ }
232+
213233 public synchronized void sendRawLine (String line ) {
214234 if (writer == null ) return ;
215235 try {
@@ -220,7 +240,29 @@ public synchronized void sendRawLine(String line) {
220240 }
221241 }
222242
223- private void processLine (String line ) {
243+ void processLine (String line ) {
244+ // Reset per-line tag state
245+ currentTagTime = null ;
246+ currentTagBatch = null ;
247+
248+ // Strip and parse IRCv3 message tags (@key=value;...)
249+ if (line .startsWith ("@" )) {
250+ int spaceIdx = line .indexOf (' ' );
251+ if (spaceIdx > 0 ) {
252+ String tagSegment = line .substring (1 , spaceIdx );
253+ line = line .substring (spaceIdx + 1 );
254+ for (String tag : tagSegment .split (";" )) {
255+ int eq = tag .indexOf ('=' );
256+ if (eq > 0 ) {
257+ String key = tag .substring (0 , eq );
258+ String value = tag .substring (eq + 1 );
259+ if ("time" .equals (key )) currentTagTime = value ;
260+ else if ("batch" .equals (key )) currentTagBatch = value ;
261+ }
262+ }
263+ }
264+ }
265+
224266 Matcher matcher = MESSAGE_PATTERN .matcher (line );
225267
226268 if (matcher .matches ()) {
@@ -258,6 +300,33 @@ private void processLine(String line) {
258300 private void processCommand (String source , String command , List <String > params ) {
259301 String sourceNick = extractNick (source );
260302
303+ // IRCv3 batch intercept: accumulate tagged messages instead of processing normally
304+ if (currentTagBatch != null && activeBatches .containsKey (currentTagBatch )) {
305+ String batchRef = currentTagBatch ;
306+ if (command .equalsIgnoreCase ("PRIVMSG" ) && params .size () >= 2 ) {
307+ String target = params .get (0 );
308+ String msgBody = params .get (1 );
309+ if (msgBody .startsWith ("\u0001 " ) && msgBody .endsWith ("\u0001 " )) {
310+ // CTCP — check for ACTION
311+ String ctcp = msgBody .substring (1 , msgBody .length () - 1 );
312+ String [] parts = ctcp .split (" " , 2 );
313+ if ("ACTION" .equals (parts [0 ])) {
314+ String actionText = parts .length > 1 ? parts [1 ] : "" ;
315+ activeBatches .get (batchRef ).add (
316+ new IrcEvent (IrcEvent .Type .ACTION , sourceNick , target , actionText , currentTagTime )
317+ );
318+ }
319+ } else {
320+ activeBatches .get (batchRef ).add (
321+ new IrcEvent (IrcEvent .Type .MESSAGE , sourceNick , target , msgBody , currentTagTime )
322+ );
323+ }
324+ return ;
325+ }
326+ // Non-PRIVMSG commands in a chathistory batch are silently ignored
327+ return ;
328+ }
329+
261330 switch (command .toUpperCase ()) {
262331 case "PRIVMSG" :
263332 if (params .size () >= 2 ) {
@@ -278,6 +347,9 @@ private void processCommand(String source, String command, List<String> params)
278347 String channel = params .get (0 );
279348 fireEvent (new IrcEvent (IrcEvent .Type .JOIN , sourceNick , channel , null , null ));
280349 channelUsers .computeIfAbsent (channel , k -> new HashSet <>()).add (sourceNick );
350+ if (sourceNick .equals (nick ) && capHistorySupported ) {
351+ sendRawLine ("CHATHISTORY LATEST " + channel + " * 100" );
352+ }
281353 }
282354 break ;
283355
@@ -288,9 +360,11 @@ private void processCommand(String source, String command, List<String> params)
288360
289361 if (!sourceNick .equals (nick )) {
290362 fireEvent (new IrcEvent (IrcEvent .Type .PART , sourceNick , channel , reason , null ));
291- }
292- if (channelUsers .containsKey (channel )) {
293- channelUsers .get (channel ).remove (sourceNick );
363+ if (channelUsers .containsKey (channel )) {
364+ channelUsers .get (channel ).remove (sourceNick );
365+ }
366+ } else {
367+ channelUsers .remove (channel );
294368 }
295369 }
296370 break ;
@@ -339,8 +413,12 @@ private void processCommand(String source, String command, List<String> params)
339413 String kickMessage = params .size () > 2 ? params .get (2 ) : "" ;
340414
341415 fireEvent (new IrcEvent (IrcEvent .Type .KICK , sourceNick , channel , kickedUser + " " + kickMessage , null ));
342- if (channelUsers .containsKey (channel )) {
343- channelUsers .get (channel ).remove (kickedUser );
416+ if (!kickedUser .equals (nick )) {
417+ if (channelUsers .containsKey (channel )) {
418+ channelUsers .get (channel ).remove (kickedUser );
419+ }
420+ } else {
421+ channelUsers .remove (channel );
344422 }
345423 }
346424 break ;
@@ -381,6 +459,77 @@ private void processCommand(String source, String command, List<String> params)
381459 }
382460 break ;
383461
462+ case "BATCH" :
463+ if (params .isEmpty ()) break ;
464+ String batchToken = params .get (0 );
465+ if (batchToken .startsWith ("+" )) {
466+ String ref = batchToken .substring (1 );
467+ // params = [+ref, type, channel]
468+ String batchChannel = params .size () >= 3 ? params .get (2 ) : "" ;
469+ activeBatches .put (ref , new ArrayList <>());
470+ activeBatchChannels .put (ref , batchChannel );
471+ } else if (batchToken .startsWith ("-" )) {
472+ String ref = batchToken .substring (1 );
473+ List <IrcEvent > accumulated = activeBatches .remove (ref );
474+ String batchChannel = activeBatchChannels .remove (ref );
475+ if (accumulated != null && batchChannel != null ) {
476+ fireEvent (new IrcEvent (IrcEvent .Type .HISTORY_BATCH , null , batchChannel , null , null , accumulated ));
477+ }
478+ }
479+ break ;
480+
481+ case "CAP" :
482+ if (params .size () < 2 ) break ;
483+ String capSubCommand = params .get (1 ).toUpperCase ();
484+ switch (capSubCommand ) {
485+ case "LS" : {
486+ // Check for multi-line continuation: params = [clientNick, LS, *, cap-list] vs [clientNick, LS, cap-list]
487+ boolean isContinuation = params .size () >= 4 && "*" .equals (params .get (2 ));
488+ String capList = isContinuation ? params .get (3 ) : (params .size () >= 3 ? params .get (2 ) : "" );
489+ for (String cap : capList .split (" " )) {
490+ if (!cap .isEmpty ()) {
491+ int eq = cap .indexOf ('=' );
492+ advertisedCaps .add (eq > 0 ? cap .substring (0 , eq ) : cap );
493+ }
494+ }
495+ if (!isContinuation ) {
496+ // Final LS line: decide what to request
497+ List <String > toRequest = new ArrayList <>();
498+ if (advertisedCaps .contains ("chathistory" )) toRequest .add ("chathistory" );
499+ if (advertisedCaps .contains ("batch" )) toRequest .add ("batch" );
500+ if (advertisedCaps .contains ("server-time" )) toRequest .add ("server-time" );
501+ if (!toRequest .isEmpty ()) {
502+ sendRawLine ("CAP REQ :" + String .join (" " , toRequest ));
503+ } else if (!capEndSent ) {
504+ sendRawLine ("CAP END" );
505+ capEndSent = true ;
506+ }
507+ }
508+ break ;
509+ }
510+ case "ACK" : {
511+ String acked = params .size () >= 3 ? params .get (2 ) : "" ;
512+ for (String cap : acked .split (" " )) {
513+ if ("chathistory" .equals (cap .trim ())) {
514+ capHistorySupported = true ;
515+ }
516+ }
517+ if (!capEndSent ) {
518+ sendRawLine ("CAP END" );
519+ capEndSent = true ;
520+ }
521+ break ;
522+ }
523+ case "NAK" :
524+ capHistorySupported = false ;
525+ if (!capEndSent ) {
526+ sendRawLine ("CAP END" );
527+ capEndSent = true ;
528+ }
529+ break ;
530+ }
531+ break ;
532+
384533 default :
385534 if (NUMERIC .matcher (command ).matches ()) {
386535 int numeric = Integer .parseInt (command );
@@ -533,21 +682,29 @@ public static class IrcEvent {
533682 public enum Type {
534683 CONNECT , DISCONNECT , REGISTERED , MESSAGE , ACTION , JOIN , PART , QUIT ,
535684 NICK_CHANGE , KICK , NOTICE , SERVER_NOTICE , CHANNEL_MODE , USER_MODE ,
536- TOPIC , NAMES , NICK_IN_USE , ERROR , TOPIC_INFO , BAD_CHANNEL_KEY , WHOIS_REPLY
685+ TOPIC , NAMES , NICK_IN_USE , ERROR , TOPIC_INFO , BAD_CHANNEL_KEY , WHOIS_REPLY ,
686+ HISTORY_BATCH
537687 }
538688
539689 private final Type type ;
540690 private final String source ;
541691 private final String target ;
542692 private final String message ;
543693 private final String additionalData ;
694+ private final List <IrcEvent > historyMessages ;
544695
545- public IrcEvent (Type type , String source , String target , String message , String additionalData ) {
696+ public IrcEvent (Type type , String source , String target , String message ,
697+ String additionalData , List <IrcEvent > historyMessages ) {
546698 this .type = type ;
547699 this .source = source ;
548700 this .target = target ;
549701 this .message = message ;
550702 this .additionalData = additionalData ;
703+ this .historyMessages = historyMessages ;
704+ }
705+
706+ public IrcEvent (Type type , String source , String target , String message , String additionalData ) {
707+ this (type , source , target , message , additionalData , null );
551708 }
552709 }
553710}
0 commit comments