ircbot/source/app.d

391 lines
9.4 KiB
D

/+
Written in 2019 by Les De Ridder <ircbot@lesderid.net>
To the extent possible under law, the author has dedicated all copyright
and related and neighboring rights to this software to the public domain
worldwide. This software is distributed without any warranty.
+/
import std.stdio;
import std.regex;
import std.parallelism;
import std.algorithm;
import std.range;
import std.string;
import std.typecons;
import core.thread;
import std.process;
import std.datetime.stopwatch;
import std.conv : to;
import core.sys.posix.signal : SIGKILL;
import core.sys.posix.poll;
import std.socket;
import deimos.openssl.ssl;
extern(C): int SSL_has_pending(const(SSL)* s);
enum Server = "irc.soupwhale.com";
enum Port = 6697;
enum TLS = true;
enum Username = "ircbot";
enum RealName = "ircbot";
enum Nickname = "ircbot";
enum Channels = ["#soupwhale"];
enum Shell = "bash";
enum ProcessTimeout = 60.seconds;
enum SleepDuration = 100.msecs;
void main()
{
static assert(TLS, "Non-TLS client not implemented");
auto ircClient = new IRCClient!TLSClient();
ircClient.connect(Server, Port);
ircClient.write(Message(null, "USER", [Username, "0", "*", RealName], true));
ircClient.write(Message(null, "NICK", [Nickname]));
auto processRunning = false;
ProcessPipes pipes;
StopWatch stopWatch;
while (true)
{
if (processRunning)
{
if (pipes.stdout.eof)
{
processRunning = false;
}
else
{
auto stdoutFD = pollfd(pipes.stdout.fileno, POLLIN);
while (poll(&stdoutFD, 1, 0) > 0 && !pipes.stdout.eof)
{
auto line = pipes.stdout.readln;
if (line.length > 0)
{
foreach (channel; Channels)
{
ircClient.write(Message(null, "PRIVMSG", [channel, line], true));
}
}
}
pollfd stderrFD = { pipes.stderr.fileno, POLLIN };
while (poll(&stderrFD, 1, 0) > 0 && !pipes.stderr.eof)
{
auto line = pipes.stderr.readln;
if (line.length > 0)
{
foreach (channel; Channels)
{
ircClient.write(Message(null, "PRIVMSG", [channel, "stderr: " ~ line], true));
}
}
}
}
//if (stopWatch.peek() > ProcessTimeout)
//{
// kill(pipes.pid, SIGKILL);
// processRunning = false;
// foreach (channel; Channels)
// {
// ircClient.write(Message(null, "PRIVMSG", [channel, "Process timed out"]));
// }
//}
}
auto maybeMessages = ircClient.readMessages();
if (maybeMessages.isNull)
{
Thread.sleep(SleepDuration);
continue;
}
foreach (message; maybeMessages.get)
{
writeln("message={", message.toString, "}");
switch (message.command)
{
case "PING":
ircClient.write(Message(null, "PONG", message.parameters));
break;
case "376":
writeln("376 376 376");
ircClient.join(Channels);
break;
case "PRIVMSG":
auto source = message.parameters[0];
auto line = message.parameters[1];
if (Channels.canFind(source))
{
if (line.startsWith("$") && !processRunning)
{
auto command = line.drop(1);
pipes = pipeProcess([Shell, "-c", command], Redirect.all, null);
//stopWatch = StopWatch(AutoStart.yes);
processRunning = true;
}
else if (processRunning)
{
if (!pipes.stdin.isOpen)
{
processRunning = false;
continue;
}
if (line == "KILL")
{
kill(pipes.pid, SIGKILL);
}
else if (line == "^D")
{
pipes.stdin.close();
}
else
{
pipes.stdin.writeln(line);
pipes.stdin.flush();
}
}
}
break;
default:
break;
}
}
}
}
//TODO: Write a compatible non-TLS Client class
class IRCClient(Client)
{
private Client _client;
this()
{
_client = new Client();
}
void connect(string address, ushort port)
{
_client.connect(address, port);
}
void disconnect()
{
_client.disconnect();
}
Nullable!(Message[]) readMessages(bool blocking = false)
{
string readString = null;
do {
readString = _client.readTo!"\r\n";
} while (blocking && readString is null);
return readString is null ? Nullable!(Message[])() : (readString.splitLines.map!(l => Message.fromString(l)).array).nullable;
}
void write(Message message)
{
write(message.toString());
}
void write(string message)
{
_client.write(message ~ "\r\n");
}
void join(string[] channels)
{
foreach (channel; channels)
{
write(Message(null, "JOIN", [channel]));
}
}
}
class TLSClient
{
private bool _connected;
@property bool connected() { return _connected; }
private SSL_CTX* _sslContext;
private SSL* _ssl;
private Socket _socket;
const ReadBufferSize = 65536;
private char[ReadBufferSize] _readBuffer;
this()
{
_sslContext = SSL_CTX_new(TLSv1_2_client_method());
assert(_sslContext !is null);
}
~this()
{
if(connected)
{
disconnect();
}
SSL_CTX_free(_sslContext);
}
void connect(string address, ushort port)
{
if(connected)
{
disconnect();
}
_socket = new TcpSocket();
_socket.connect(new InternetAddress(address, port));
_ssl = SSL_new(_sslContext);
SSL_set_fd(_ssl, _socket.handle());
assert(SSL_connect(_ssl) != -1);
_socket.blocking = false;
_connected = true;
}
void disconnect()
{
SSL_free(_ssl);
_socket.close();
_connected = false;
}
string readTo(string delimiter, bool includeDelimiter = false)()
{
auto totalBytesRead = 0;
auto bytesRead = 0;
while(totalBytesRead == 0 || (totalBytesRead >= delimiter.length && _readBuffer[totalBytesRead - delimiter.length .. totalBytesRead] != delimiter))
{
bytesRead = SSL_read(_ssl, cast(void*) _readBuffer + totalBytesRead, ReadBufferSize - totalBytesRead);
if (bytesRead <= 0) return null;
totalBytesRead += bytesRead;
}
return _readBuffer[0 .. (includeDelimiter ? totalBytesRead : totalBytesRead - delimiter.length)].idup;
}
void write(string message)
{
auto bytesWritten = SSL_write(_ssl, cast(const char*) message, cast(int) message.length);
if (bytesWritten <= 0)
{
stderr.writeln("uh-oh");
disconnect();
}
}
}
struct Message
{
string prefix;
string command;
string[] parameters;
bool prefixedParameter;
//NOTE: The RFCs don't state what this is exactly, but common implementations use the byte count of the message parameters
ulong bytes;
static Message fromString(string line)
{
string prefix = null;
if(line.startsWith(':'))
{
line = line[1 .. $];
prefix = line[0 .. line.indexOf(' ')];
line = line[prefix.length + 1 .. $];
}
//stop early when no space character can be found (message without parameters)
if(!line.canFind(' '))
{
return Message(prefix, line, [], false);
}
auto command = line[0 .. line.indexOf(' ')];
line = line[command.length + 1 .. $];
auto bytes = line.length;
string[] params = [];
bool prefixedParam;
while(true)
{
if(line.startsWith(':'))
{
params ~= line[1 .. $];
prefixedParam = true;
break;
}
else if(line.canFind(' '))
{
auto param = line[0 .. line.indexOf(' ')];
line = line[param.length + 1 .. $];
params ~= param;
}
else
{
params ~= line;
break;
}
}
return Message(prefix, command, params, prefixedParam, bytes);
}
string toString()
{
auto message = "";
if(prefix != null)
{
message = ":" ~ prefix ~ " ";
}
if(parameters.length == 0)
{
return message ~ command;
}
message ~= command ~ " ";
if(parameters.length > 1)
{
message ~= parameters[0 .. $-1].join(' ') ~ " ";
}
if(parameters[$-1].canFind(' ') || prefixedParameter)
{
message ~= ":";
}
message ~= parameters[$-1];
return message;
}
}