Commit 32d3a5f1 authored by Charmed Baryon's avatar Charmed Baryon

First commit with initial version and support files.

parents
Pipeline #57128955 passed with stages
in 3 minutes and 52 seconds
# Created by https://www.gitignore.io/api/java,maven,eclipse,intellij
# Edit at https://www.gitignore.io/?templates=java,maven,eclipse,intellij
### Eclipse ###
.metadata
bin/
tmp/
*.tmp
*.bak
*.swp
*~.nib
local.properties
.settings/
.loadpath
.recommenders
# External tool builders
.externalToolBuilders/
# Locally stored "Eclipse launch configurations"
*.launch
# PyDev specific (Python IDE for Eclipse)
*.pydevproject
# CDT-specific (C/C++ Development Tooling)
.cproject
# CDT- autotools
.autotools
# Java annotation processor (APT)
.factorypath
# PDT-specific (PHP Development Tools)
.buildpath
# sbteclipse plugin
.target
# Tern plugin
.tern-project
# TeXlipse plugin
.texlipse
# STS (Spring Tool Suite)
.springBeans
# Code Recommenders
.recommenders/
# Annotation Processing
.apt_generated/
# Scala IDE specific (Scala & Java development for Eclipse)
.cache-main
.scala_dependencies
.worksheet
### Eclipse Patch ###
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
# Annotation Processing
.apt_generated
.sts4-cache/
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# JetBrains templates
**___jb_tmp___
### Intellij Patch ###
*.iml
modules.xml
.idea/misc.xml
*.ipr
# Sonarlint plugin
.idea/sonarlint
### Java ###
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
### Maven ###
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# End of https://www.gitignore.io/api/java,maven,eclipse,intellij
image: maven:3.6.0-jdk-11
cache:
paths:
- .m2
key: "$CI_BUILD_REF_NAME"
variables:
MAVEN_OPTS: "-Dmaven.repo.local=.m2"
stages:
- build
- unit-test
- integration-test
# - deploy
build:
stage: build
script:
- git clone https://gitlab.com/proticity/cloud/java-next-pom.git
- cd java-next-pom
- mvn install -Dmaven.repo.local=../.m2
- cd ..
- mvn compile
unit-test:
stage: unit-test
script:
- mvn test
integration-test:
stage: integration-test
script:
- mvn integration-test -DskipTests
#deploy:
# stage: deploy
# script:
# - mvn deploy
# only:
# - master
This diff is collapsed.
# Reactive IRC Client
This library is a reactive client for IRC and IRCv3/TMI (Twitch) extensions over TCP and WebSocket. It uses the Reactor
library for functional reactive streams to achieve very high throughput and non-blocking semantics.
## Features
* Standards-compliant support for the IRC RFC.
* Reduced strictness on some syntax (e.g. hostnames) to support extended servers such as Twitch's TMI.
* IRCv3 tagging and capabilities support.
* Functional reactive streams interface for high-performance non-blocking handling of I/O on top of Reactor Netty.
* Extensible support for IRC over various transports, with TCP and WebSocket available out of the box.
* Secure transport support (support for IRC over TLS).
* Strongly-typed event interfaces for incoming IRC commands.
## Adding the Library
Maven:
```xml
<dependencies>
<dependency>
<groupId>com.proticity.irc</groupId>
<artifactId>reactive-irc-client</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
</dependencies>
```
Gradle:
```groovy
dependencies {
implementation 'com.proticity.irc:reactive-irc-client:1.0.0-SNAPSHOT'
}
```
## Using the Library
### Creating a Client
#### Standard IRC Server
A typical IRC server is available via unsecured TCP on port 6667. A `TcpTransport` will support default to port 6667.
```java
IrcClient client = IrcClient.create()
.transport(TcpTransport.createInsecure("chat.freenode.net"))
.nickname("BotUser")
.realName("Bot User")
.connect();
```
If the server supports TLS a secure transport can be created.
```java
IrcClient client = IrcClient.create()
.transport(TcpTransport.createSecure("chat.freenode.net", 6697))
.nickname("BotUser")
.realName("Bot User")
.connect();
```
The default port for a secure TCP transport is 6697 if none is given. For simplicity, `IrcClientBuilder#tcp()` can be
used to create a secure (and only a secure) `TcpTransport`.
```java
IrcClient client = IrcClient.create()
.tcp("chat.freenode.net")
.nickname("BotUser")
.realName("Bot User")
.connect();
```
#### WebSocket Clients
The `WebSocketTransport` supports connection over WebSocket. It's API is similar to that of `TcpTransport`.
```java
var insecureTransport = WebSocketTransport.createInsecure("ws://chat.server.com");
var secureTransport = WebSocketTransport.createInsecure("wss://chat.server.com");
```
These can be used with the builder's `IrcClientBuilder#transport()` method, or the convenience
`IrcClientBuilder#webSocket()` method can used.
```java
IrcClient client = IrcClient.create()
.webSocket("wss://chat.server.com")
.nickname("BotUser")
.realName("Bot User")
.connect();
```
#### Twitch Support
Twitch chat uses TMI, an extension to IRC with some IRCv3 features such as tagging and capabilities. These features are
supported as are the more liberal syntactic rules for some parts of the RFC's grammar. Twitch support merely requires
connecting to a Twitch TMI endpoint via the appropriate transport and optionally providing the capabilities to the
builder that you wish to use (`"twitch.tv/tags"`, `"twitch.tv/commands"`, `"twitch.tv/membership"`). A convenience
method is provided on the builder for Twitch connections to simplify the process. This method will add the necessary
capabilities, setup the transport for a secure WebSocket connection, and, if no nickname is set, will default it to
the anonymous `"justinfan12345"` nickname.
```java
// Connect as an anonymous Twitch user.
IrcClient client = IrcClient.create().twitch().connect();
client = IrcClient.create()
.twitch()
.nickname("MyBot")
.password("oauth:abcdefg1234567")
.suppressParseErrors()
.connect();
```
The `suppressParseErrors` option is encouraged for Twitch as there are bugs in TMI where batched `JOIN` commands
are cut off after 2048 characters in normal use. This option will cause the client to simply ignore any command which
fails to parse (although it will be logged as a warning if you use an SLF4J logger).
### Receiving Commands
As a reactive library the IRC client does not connect until there is a subscriber. A `Flux` of commands from the server
will be received from the method `IrcClient#commands()`. The commands are strongly typed when possible. The base class
`IrcCommand` allows a command to be generically inspected, and typed subclasses include friendly methods for extracting
parts of the command parameters. Note that most numeric commands are generalized as a `NumericReplyCommand`.
```java
// Print all commands but call out PRIVMSG from a user specifically.
client.commands()
.doOnNext(System.out::println) // Print every command to stdout
.filter(cmd -> cmd instanceof PrivmsgCommand) // Then filter for just PRIVMSG
.cast(PrivmsgCommand.class)
.filter(cmd -> cmd.getPrefix().isPresent() && cmd.getPrefix().get() instanceof NicknamePrefix)
.subscribe(cmd -> {
System.out.println("You received a message from " + ((NicknamePrefix) cmd.getPrefix().get()).getNickname());
});
```
#### Twitch Commands
When using Twitch with the appropriate command capability there are some extensions to IRC that Twitch has. This
library has strongly-typed Twitch command subclasses out of the box and will use them when received from a TMI server.
<project>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.proticity</groupId>
<artifactId>java-next-pom</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<groupId>com.proticity.irc</groupId>
<artifactId>reactive-irc-client</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<scm>
<url>https://gitlab.com/proticity/cloud/reactive-irc-client</url>
<connection>scm:git:https://gitlab.com/proticity/cloud/reactive-irc-client</connection>
<developerConnection>scm:git:git@gitlab.com:proticity/cloud/reactive-irc-client.git</developerConnection>
</scm>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
<distribution>repo</distribution>
</license>
</licenses>
<dependencies>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
This diff is collapsed.
package com.proticity.irc.client.command;
import reactor.util.annotation.NonNull;
import reactor.util.annotation.Nullable;
import java.io.Serializable;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Pattern;
public class Capability implements Serializable {
private static final Pattern CAPABILITY =
Pattern.compile("^((?<vendor>[a-zA-Z0-9](-[a-zA-Z0-9]|[a-zA-Z0-9])*(\\.[a-zA-Z0-9](-[a-zA-Z0-9]|[a-zA-Z0-9])*)*)/)?(?<name>[a-zA-Z0-9\\-]+)$");
private String vendor;
private String name;
public Capability(@NonNull String name) {
var matcher = CAPABILITY.matcher(name);
if (!matcher.lookingAt()) {
throw new IllegalArgumentException("Name is not in the correct capability format.");
}
setVendor(matcher.group("vendor"));
setName(matcher.group("name"));
}
public Capability(@Nullable String vendor, @NonNull String name) {
setVendor(vendor);
setName(name);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Capability that = (Capability) o;
return Objects.equals(vendor, that.vendor) &&
name.equals(that.name);
}
@Override
public int hashCode() {
return Objects.hash(vendor, name);
}
@NonNull
@Override
public String toString() {
return getVendor().map(v -> v + "/").orElse("") + getName();
}
@NonNull
public Optional<String> getVendor() {
return Optional.ofNullable(vendor);
}
public void setVendor(@Nullable String vendor) {
this.vendor = vendor;
}
@NonNull
public String getName() {
return name;
}
public void setName(@NonNull String name) {
this.name = name;
}
}
package com.proticity.irc.client.command;
import reactor.util.annotation.NonNull;
import java.io.Serializable;
import java.util.Objects;
import java.util.regex.Pattern;
public class Channel implements Serializable {
private static final Pattern CHANNEL = Pattern.compile("^(?<prefix>[#+&]|(![A-Z0-9]{5}))(?<name>[^ \0\r\n:,\u0007]+)$");
private String prefix;
private String name;
public Channel(@NonNull String channel) {
var matcher = CHANNEL.matcher(channel);
if (!matcher.lookingAt()) {
throw new IllegalArgumentException("Channel expression '" + channel + "' is not valid.");
}
setPrefix(matcher.group("prefix"));
setName(matcher.group("name"));
}
public Channel(@NonNull String prefix, @NonNull String name) {
setPrefix(prefix);
setName(name);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Channel channel = (Channel) o;
return prefix.equals(channel.prefix) &&
name.equals(channel.name);
}
@Override
public int hashCode() {
return Objects.hash(prefix, name);
}
@NonNull
@Override
public String toString() {
return getPrefix() + getName();
}
@NonNull
public String getPrefix() {
return prefix;
}
public void setPrefix(@NonNull String prefix) {
this.prefix = prefix;
}
@NonNull
public String getName() {
return name;
}
public void setName(@NonNull String name) {
this.name = name;
}
}
package com.proticity.irc.client.command;
import reactor.util.annotation.NonNull;
import reactor.util.annotation.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class CommandBuilder {
private Map<TagKey, String> tags;
private Prefix prefix;
private String command;
private List<String> parameters;
private String trailingParameter;
public CommandBuilder() {
}
public CommandBuilder tag(@NonNull TagKey key, @Nullable String value) {
if (tags == null) {
tags = new HashMap<>();
}
tags.put(key, value);
return this;
}
public CommandBuilder prefix(@Nullable Prefix prefix) {
this.prefix = prefix;
return this;
}
public CommandBuilder command(@NonNull String command) {
this.command = command;
return this;
}
public CommandBuilder parameter(String parameter) {
if (parameters == null) {
parameters = new ArrayList<>();
}
parameters.add(parameter);
return this;
}
public CommandBuilder trailingParameter(@Nullable String trailingParameter) {
this.trailingParameter = trailingParameter;
return this;
}
@Nullable
public Map<TagKey, String> getTags() {
return tags;
}
@Nullable
public Prefix getPrefix() {
return prefix;
}
@NonNull
public String getCommand() {
return command;
}
@Nullable
public List<String> getParameters() {
return parameters;
}
@Nullable
public String getTrailingParameter() {
return trailingParameter;
}
@NonNull
public String getParameter(int index) {
if (parameters == null || parameters.size() <= index) {
throw new IllegalArgumentException("Expected parameter " + index + " not found.");
}
String param = parameters.get(index);
if (param == null) {
throw new IllegalArgumentException("Expected parameter " + index + " not found.");
}
return param;
}
}
package com.proticity.irc.client.command;
import reactor.util.annotation.NonNull;
public class ErrorCommand extends IrcCommand {
public ErrorCommand(@NonNull CommandBuilder builder) {
super(builder);
}
@NonNull
public String getMessage() {
return getTrailingParameter().get();
}
}
package com.proticity.irc.client.command;
import reactor.util.annotation.NonNull;
import java.util.Optional;
import java.util.function.Function;
/**
* A special command representing any invalid or unparsable command received from the server.
*
* This command indicates that the content could not be parsed even to a general command, i.e. it does not confirm to
* the IRC specification or any extension known to this library. An invalid command typically should be ignored by the
* client application, but logged in some way.
*
* By default when {@link com.proticity.irc.client.parser.IrcInput} encounters an error during processing of input it
* will emit an error. In cases where it is necessary to handle the input without terminating the pipeline this command
* can be used, such as via {@link reactor.core.publisher.Flux#onErrorResume(Function)} and building an
* InvalidCommand to be returned.
*/
public class InvalidCommand extends IrcCommand {
private String input;
private Exception error;
/**
* Create an invalid command for a given input string.
* @param input the invalid input.
*/
public InvalidCommand(String input) {
this.input = input;
}
/**
* Create an invalid command for a given input string and error.
* @param input the invalid input.
* @param error the error generated by the input.
*/
public InvalidCommand(String input, Exception error) {
this(input);
this.error = error;
setCommand("INVALID");
}