Skip to content

Commit 5943c9f

Browse files
authored
feat: Chat history on join (#59)
* feat: IRCv3 chat history * chore: Update to latest example plugin * fix: CAP has = in it
1 parent 96b04c2 commit 5943c9f

5 files changed

Lines changed: 239 additions & 21 deletions

File tree

build.gradle

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@ plugins {
22
id 'java'
33
}
44

5-
java {
6-
sourceCompatibility = JavaVersion.VERSION_11
7-
targetCompatibility = JavaVersion.VERSION_11
8-
}
9-
105
repositories {
116
mavenLocal()
127
maven {
138
url = 'https://repo.runelite.net'
9+
content {
10+
includeGroupByRegex("net\\.runelite.*")
11+
}
1412
}
1513
mavenCentral()
1614
}
1715

1816
def runeLiteVersion = 'latest.release'
17+
def pluginMainClass = 'com.irc.IrcPlugin'
1918

2019
dependencies {
2120
compileOnly group: 'net.runelite', name:'client', version: runeLiteVersion
@@ -28,14 +27,44 @@ dependencies {
2827
testImplementation group: 'net.runelite', name:'jshell', version: runeLiteVersion
2928
}
3029

31-
tasks.withType(AbstractArchiveTask).configureEach {
32-
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
30+
group = 'com.irc'
31+
version = '2.1'
32+
33+
tasks.withType(JavaCompile).configureEach {
34+
options.encoding = 'UTF-8'
35+
options.release.set(11)
3336
}
3437

35-
group = 'com.example'
36-
version = '1.0-SNAPSHOT'
37-
sourceCompatibility = '1.11'
38+
tasks.register('run', JavaExec) {
39+
classpath = sourceSets.test.runtimeClasspath
40+
mainClass = pluginMainClass
3841

39-
tasks.withType(JavaCompile) {
40-
options.encoding = 'UTF-8'
42+
jvmArgs "-ea"
43+
args "--developer-mode", "--debug"
4144
}
45+
46+
tasks.register('shadowJar', Jar) {
47+
dependsOn configurations.testRuntimeClasspath
48+
manifest {
49+
attributes('Main-Class': pluginMainClass, 'Multi-Release': true)
50+
}
51+
52+
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
53+
from sourceSets.main.output
54+
from sourceSets.test.output
55+
from {
56+
configurations.testRuntimeClasspath.collect { file ->
57+
file.isDirectory() ? file : zipTree(file)
58+
}
59+
}
60+
61+
exclude 'META-INF/INDEX.LIST'
62+
exclude 'META-INF/*.SF'
63+
exclude 'META-INF/*.DSA'
64+
exclude 'META-INF/*.RSA'
65+
exclude '**/module-info.class'
66+
67+
group = BasePlugin.BUILD_GROUP
68+
archiveClassifier.set('shadow')
69+
archiveFileName.set("${rootProject.name}-${project.version}-all.jar")
70+
}

src/main/java/com/irc/IrcAdapter.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,34 @@ private void setupEventHandlers() {
340340
case TOPIC_INFO:
341341
processMessage(new IrcMessage(event.getTarget(), event.getSource(), event.getMessage(), IrcMessage.MessageType.TOPIC, Instant.now()));
342342
break;
343+
344+
case HISTORY_BATCH:
345+
if (event.getHistoryMessages() != null && !event.getHistoryMessages().isEmpty()) {
346+
for (SimpleIrcClient.IrcEvent accEvent : event.getHistoryMessages()) {
347+
Instant timestamp;
348+
try {
349+
String timeStr = accEvent.getAdditionalData();
350+
timestamp = (timeStr != null && !timeStr.isEmpty())
351+
? Instant.parse(timeStr)
352+
: Instant.now();
353+
} catch (Exception e) {
354+
timestamp = Instant.now();
355+
}
356+
String sender = accEvent.getSource() != null ? accEvent.getSource() : "";
357+
if (accEvent.getType() == SimpleIrcClient.IrcEvent.Type.ACTION) {
358+
sender = "* " + sender;
359+
}
360+
processMessage(new IrcMessage(
361+
event.getTarget(), sender, accEvent.getMessage(),
362+
IrcMessage.MessageType.HISTORY, timestamp
363+
));
364+
}
365+
processMessage(new IrcMessage(
366+
event.getTarget(), "*", "--- Begin of chat ---",
367+
IrcMessage.MessageType.HISTORY_SEPARATOR, Instant.now()
368+
));
369+
}
370+
break;
343371
}
344372
});
345373
}

src/main/java/com/irc/IrcMessage.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public class IrcMessage {
1212
Instant timestamp;
1313

1414
enum MessageType {
15-
CHAT, SYSTEM, JOIN, PART, QUIT, NICK_CHANGE, PRIVATE, NOTICE, KICK, TOPIC, MODE
15+
CHAT, SYSTEM, JOIN, PART, QUIT, NICK_CHANGE, PRIVATE, NOTICE, KICK, TOPIC, MODE,
16+
HISTORY, HISTORY_SEPARATOR
1617
}
1718
}

src/main/java/com/irc/IrcPanel.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,9 @@ void appendMessage(IrcMessage message, IrcConfig config) {
518518
}
519519

520520
private String formatPanelMessage(IrcMessage message, IrcConfig config) {
521+
if (message.getType() == IrcMessage.MessageType.HISTORY_SEPARATOR) {
522+
return "<div style='color: #808080; text-align: center;'>--- Begin of chat ---</div>";
523+
}
521524
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
522525
String timeStamp = "";
523526
if (config.timestamp()) {

src/main/java/com/irc/SimpleIrcClient.java

Lines changed: 165 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)