1
0
mirror of https://github.com/deavmi/birchwood synced 2024-09-20 08:43:05 +02:00
birchwood/source/birchwood/client.d

985 lines
27 KiB
D

module birchwood.client;
import std.socket : Socket, SocketException, Address, getAddress, SocketType, ProtocolType, SocketOSException;
import std.socket : SocketFlags;
import std.conv : to;
import std.container.slist : SList;
import core.sync.mutex : Mutex;
import core.thread : Thread, dur;
import std.string;
import eventy;
import birchwood.messages : Message, encodeMessage, decodeMessage;
import birchwood.constants : ReplyType;
// TODO: Remove this import
import std.stdio : writeln;
import dlog;
__gshared Logger logger;
__gshared static this()
{
logger = new DefaultLogger();
}
public class BirchwoodException : Exception
{
public enum ErrorType
{
INVALID_CONN_INFO,
ALREADY_CONNECTED,
CONNECT_ERROR,
EMPTY_PARAMS
}
private ErrorType errType;
/* Auxillary error information */
/* TODO: Make these actually Object */
private string auxInfo;
this(ErrorType errType)
{
super("BirchwoodError("~to!(string)(errType)~")"~(auxInfo.length == 0 ? "" : " "~auxInfo));
this.errType = errType;
}
this(ErrorType errType, string auxInfo)
{
this(errType);
this.auxInfo = auxInfo;
}
public ErrorType getType()
{
return errType;
}
}
public struct ConnectionInfo
{
/* Server address information */
private Address addrInfo;
private string nickname;
/* Misc. */
/* TODO: Make final/const (find out difference) */
private ulong bulkReadSize;
/* Client behaviour (TODO: what is sleep(0), like nothing) */
private ulong fakeLag = 0;
/* The quit message */
public const string quitMessage;
/* TODO: before publishing change this bulk size */
private this(Address addrInfo, string nickname, ulong bulkReadSize = 20, string quitMessage = "birchwood client disconnecting...")
{
this.addrInfo = addrInfo;
this.nickname = nickname;
this.bulkReadSize = bulkReadSize;
this.quitMessage = quitMessage;
}
public ulong getBulkReadSize()
{
return this.bulkReadSize;
}
public Address getAddr()
{
return addrInfo;
}
/**
* Creates a ConnectionInfo struct representing a client configuration which
* can be provided to the Client class to create a new connection based on its
* parameters
*
* Params:
* hostname = hostname of the server
* port = server port
* nickname = nickname to use
* Returns: ConnectionInfo for this server
*/
public static ConnectionInfo newConnection(string hostname, ushort port, string nickname)
{
try
{
/* Attempt to resolve the address (may throw SocketException) */
Address[] addrInfo = getAddress(hostname, port);
/* Username check */
if(!nickname.length)
{
throw new BirchwoodException(BirchwoodException.ErrorType.INVALID_CONN_INFO);
}
/* TODO: Add feature to choose which address to use, prefer v4 or v6 type of thing */
Address chosenAddress = addrInfo[0];
return ConnectionInfo(chosenAddress, nickname);
}
catch(SocketException e)
{
throw new BirchwoodException(BirchwoodException.ErrorType.INVALID_CONN_INFO);
}
}
/**
* Tests invalid conneciton information
*
* 1. Invalid hostnames
* 2. Invalid usernames
*/
unittest
{
try
{
newConnection("1.", 21, "deavmi");
assert(false);
}
catch(BirchwoodException e)
{
assert(e.getType() == BirchwoodException.ErrorType.INVALID_CONN_INFO);
}
try
{
newConnection("1.1.1.1", 21, "");
assert(false);
}
catch(BirchwoodException e)
{
assert(e.getType() == BirchwoodException.ErrorType.INVALID_CONN_INFO);
}
}
}
public class Client : Thread
{
/* Connection information */
private ConnectionInfo connInfo;
/* TODO: We should learn some info in here (or do we put it in connInfo)? */
private string serverName; //TODO: Make use of
private Socket socket;
/* Message queues (and handlers) */
private SList!(ubyte[]) recvQueue, sendQueue;
private Mutex recvQueueLock, sendQueueLock;
private Thread recvHandler, sendHandler;
/* Event engine */
private Engine engine;
private bool running = false;
this(ConnectionInfo connInfo)
{
super(&loop);
this.connInfo = connInfo;
}
~this()
{
//TODO: Do something here, tare downs
}
private final enum EventType : ulong
{
GENERIC_EVENT = 1,
PONG_EVENT
}
/* TODO: Move to an events.d class */
private final class IRCEvent : Event
{
private Message msg;
this(Message msg)
{
super(EventType.GENERIC_EVENT, null);
this.msg = msg;
}
public Message getMessage()
{
return msg;
}
public override string toString()
{
return msg.toString();
}
}
/* TODO: make PongEvent (id 2 buit-in) */
private final class PongEvent : Event
{
private string pingID;
this(string pingID)
{
super(EventType.PONG_EVENT);
this.pingID = pingID;
}
public string getID()
{
return pingID;
}
}
/**
* User overridable handler functions below
*/
public void onChannelMessage(Message fullMessage, string channel, string msgBody)
{
/* Default implementation */
logger.log("Channel("~channel~"): "~msgBody);
}
public void onDirectMessage(Message fullMessage, string nickname, string msgBody)
{
/* Default implementation */
logger.log("DirectMessage("~nickname~"): "~msgBody);
}
public void onGenericCommand(Message message)
{
/* Default implementation */
logger.log("Generic("~message.getCommand()~", "~message.getFrom()~"): "~message.getParams());
}
public void onCommandReply(Message commandReply)
{
/* Default implementation */
logger.log("Response("~to!(string)(commandReply.getReplyType())~", "~commandReply.getFrom()~"): "~commandReply.toString());
}
/**
* User operations (request-response type)
*/
/**
* Joins the requested channel
*
* Params:
* channel = the channel to join
*/
public void joinChannel(string channel)
{
/* Join the channel */
sendMessage("JOIN "~channel);
}
/**
* Parts from a list of channel(s) in one go
*
* Params:
* channels = the list of channels to part from
* Throws:
* BirchwoodException if the list is empty
*/
public void leaveChannel(string[] channels)
{
// TODO: Add check for valid and non-empty channel names
/* If single channel */
if(channels.length == 1)
{
/* Leave the channel */
leaveChannel(channels[0]);
}
/* If multiple channels */
else if(channels.length > 1)
{
string channelLine = channels[0];
for(ulong i = 1; i < channels.length; i++)
{
string currentChannel = channels[i];
if(i == channels.length-1)
{
channelLine~=currentChannel;
}
else
{
channelLine~=currentChannel~",";
}
}
/* Leave multiple channels */
sendMessage("PART "~channelLine);
}
/* If no channels provided at all (error) */
else
{
throw new BirchwoodException(BirchwoodException.ErrorType.EMPTY_PARAMS);
}
}
/**
* Part from a single channel
*
* Params:
* channel = the channel to leave
*/
public void leaveChannel(string channel)
{
// TODO: Add check for valid and non-empty channel names
/* Leave the channel */
sendMessage("PART "~channel);
}
/**
* Sends a direct message to the intended recipients
*
* Params:
* message = The message to send
* recipients = The receipients of the message
*/
@disable
public void directMessage(string message, string[] recipients)
{
//TODO: Implement
}
/**
* Sends a message to a given channel
*
* Params:
* message = The message to send
* channel = The channel to send the message to
*/
@disable
public void channelMessage(string message, string channel)
{
//TODO: Implement
}
/**
* Issues a command to the server
*
* Params:
* message = the Message object containing the command to issue
*/
public void command(Message message)
{
/* Encode according to EBNF format */
// TODO: Validty check
// TODO: Make `Message.encode()` actually encode instead of empty string
string stringToSend = message.encode();
/* Send the message */
sendMessage(stringToSend);
}
/**
* Initialize the event handlers
*/
private void initEvents()
{
/* TODO: For now we just register one signal type for all messages */
/* Register all event types */
engine.addQueue(EventType.GENERIC_EVENT);
engine.addQueue(EventType.PONG_EVENT);
/* Base signal with IRC client in it */
abstract class BaseSignal : Signal
{
/* ICR client */
private Client client;
this(Client client, ulong[] eventIDs)
{
super(eventIDs);
this.client = client;
}
}
/* Handles all IRC messages besides PING */
class GenericSignal : BaseSignal
{
this(Client client)
{
super(client, [EventType.GENERIC_EVENT]);
}
public override void handler(Event e)
{
/* TODO: Insert cast here to our custoim type */
IRCEvent ircEvent = cast(IRCEvent)e;
assert(ircEvent); //Should never fail, unless some BOZO regged multiple handles for 1 - wait idk does eventy do that even mmm
// NOTE: Enable this when debugging
// logger.log("IRCEvent(message): "~ircEvent.getMessage().toString());
/* TODO: We should use a switch statement, imagine how nice */
Message ircMessage = ircEvent.getMessage();
string command = ircMessage.getCommand();
string params = ircMessage.getParams();
if(cmp(command, "PRIVMSG") == 0)
{
/* Split up into (channel/nick) and (message)*/
long firstSpaceIdx = indexOf(params, " "); //TODO: validity check;
string chanNick = params[0..firstSpaceIdx];
/* Extract the message from params */
long firstColonIdx = indexOf(params, ":"); //TODO: validity check
string message = params[firstColonIdx+1..params.length];
/* If it starts with `#` then channel */
if(chanNick[0] == '#')
{
/* Call the channel message handler */
onChannelMessage(ircMessage, chanNick, message);
}
/* Else, direct message */
else
{
/* Call the direct message handler */
onDirectMessage(ircMessage, chanNick, message);
}
}
// If the command is numeric then it is a reply of some sorts
else if(ircMessage.isResponseMessage())
{
/* Call the command reply handler */
onCommandReply(ircMessage);
}
/* Generic handler */
else
{
onGenericCommand(ircMessage);
}
//TODO: add more commands
}
}
engine.addSignalHandler(new GenericSignal(this));
/* Handles PING messages */
class PongSignal : BaseSignal
{
this(Client client)
{
super(client, [EventType.PONG_EVENT]);
}
/* Send a PONG back with the received PING id */
public override void handler(Event e)
{
PongEvent pongEvent = cast(PongEvent)e;
assert(pongEvent);
string messageToSend = "PONG "~pongEvent.getID();
client.sendMessage(messageToSend);
logger.log("Ponged");
}
}
engine.addSignalHandler(new PongSignal(this));
}
/**
* Connects to the server
*/
/**
* Connects to the server
*
* Throws: BirchwoodException
*/
public void connect()
{
if(socket is null)
{
try
{
/* Attempt to connect */
this.socket = new Socket(connInfo.getAddr().addressFamily(), SocketType.STREAM, ProtocolType.TCP);
this.socket.connect(connInfo.getAddr());
/* Initialize queue locks */
this.recvQueueLock = new Mutex();
this.sendQueueLock = new Mutex();
/* Start the event engine */
this.engine = new Engine();
this.engine.start();
/* Regsiter default handler */
initEvents();
/* TODO: Clean this up and place elsewhere */
this.recvHandler = new Thread(&recvHandlerFunc);
this.recvHandler.start();
this.sendHandler = new Thread(&sendHandlerFunc);
this.sendHandler.start();
/* Set running sttaus to true */
running = true;
/* Start socket loop */
this.start();
}
catch(SocketOSException e)
{
throw new BirchwoodException(BirchwoodException.ErrorType.CONNECT_ERROR);
}
}
else
{
throw new BirchwoodException(BirchwoodException.ErrorType.ALREADY_CONNECTED);
}
}
/**
* Adds a given message onto the receieve queue for
* later processing by the receieve queue worker thread
*
* Params:
* message = the message to enqueue to the receieve queue
*/
private void receiveQ(ubyte[] message)
{
/* Lock queue */
recvQueueLock.lock();
/* Add to queue */
recvQueue.insertAfter(recvQueue[], message);
/* Unlock queue */
recvQueueLock.unlock();
}
/* TODO: Spawn a thread worker that reacts */
/**
* This function is run as part of the "reactor"
* thread and its job is to effectively dequeue
* messages from the receive queue and call the
* correct handler function with the message as
* the event payload.
*
* It pays high priority to looking for a PING
* message first and handling those and then doing
* a second pass for other messages
*
* TODO: Do decode here and triggering of events here
*/
/**
* The receive queue worker function
*
* This has the job of dequeuing messages
* in the receive queue, decoding them
* into Message objects and then emitting
* an event depending on the type of message
*
* Handles PINGs along with normal messages
*/
private void recvHandlerFunc()
{
while(running)
{
/* Lock the receieve queue */
recvQueueLock.lock();
/* Message being analysed */
Message curMsg;
/* Search for a PING */
ubyte[] pingMessage;
ulong pos = 0;
foreach(ubyte[] message; recvQueue[])
{
if(indexOf(cast(string)message, "PING") > -1)
{
pingMessage = message;
recvQueue.linearRemoveElement(message);
break;
}
pos++;
}
/**
* TODO: Plan of action
*
* 1. Firstly, we must run `parseReceivedMessage()` on the dequeued
* ping message (if any)
* 2. Then (if there was a PING) trigger said PING handler
* 3. Normal message handling; `parseReceivedMessage()` on one of the messages
* (make the dequeue amount configurable possibly)
* 4. Trigger generic handler
* 5. We might need to also have a queue for commands ISSUED and command-replies
* RECEIVED and then match those first and do something with them (tasky-esque)
* 6. We can just make a generic reply queue of these things - we have to maybe to this
* - we can cache or remember stuff when we get 353
*/
/* If we found a PING */
if(pingMessage.length > 0)
{
/* Decode the message and parse it */
curMsg = Message.parseReceivedMessage(decodeMessage(pingMessage));
logger.log("Found a ping: "~curMsg.toString());
// string ogMessage = cast(string)pingMessage;
// long idxSigStart = indexOf(ogMessage, ":")+1;
// long idxSigEnd = lastIndexOf(ogMessage, '\r');
// string pingID = ogMessage[idxSigStart..idxSigEnd];
string pingID = curMsg.getParams();
// this.socket.send(encodeMessage("PONG "~pingID));
// string messageToSend = "PONG "~pingID;
// sendMessage(messageToSend);
// logger.log("Ponged");
/* TODO: Implement */
Event pongEvent = new PongEvent(pingID);
engine.push(pongEvent);
}
/* Now let's go message by message */
if(!recvQueue.empty())
{
ubyte[] message = recvQueue.front();
/* Decode message */
string messageNormal = decodeMessage(message);
recvQueue.linearRemoveElement(recvQueue.front());
// writeln("Normal message: "~messageNormal);
/* TODO: Parse message and call correct handler */
curMsg = Message.parseReceivedMessage(messageNormal);
Event ircEvent = new IRCEvent(curMsg);
engine.push(ircEvent);
}
/* Unlock the receive queue */
recvQueueLock.unlock();
/* TODO: Threading yield here */
Thread.yield();
}
}
/**
* The send queue worker function
*/
private void sendHandlerFunc()
{
/* TODO: Hoist up into ConnInfo */
ulong fakeLagInBetween = 1;
while(running)
{
/* TODO: handle normal messages (xCount with fakeLagInBetween) */
/* Lock queue */
sendQueueLock.lock();
foreach(ubyte[] message; sendQueue[])
{
this.socket.send(message);
Thread.sleep(dur!("seconds")(fakeLagInBetween));
}
/* Empty the send queue */
sendQueue.clear();
/* Unlock queue */
sendQueueLock.unlock();
/* TODO: Yield */
Thread.yield();
}
}
/**
* TODO: Make send queue which is used on another thread to send messages
*
* This allows us to intrpoduce fakelag and also prioritse pongs (we should
* send them via here)
*/
/**
* Sends a message to the server by enqueuing it on
* the client-side send queue
*
* Params:
* messageOut = the message to send
*/
private void sendMessage(string messageOut)
{
/* Encode the mesage */
ubyte[] encodedMessage = encodeMessage(messageOut);
/* Lock queue */
sendQueueLock.lock();
/* Add to queue */
sendQueue.insertAfter(sendQueue[], encodedMessage);
/* Unlock queue */
sendQueueLock.unlock();
}
/**
* Disconnect from the IRC server gracefully
*/
public void quit()
{
/* Generate the quit command using the custom quit message */
Message quitCommand = new Message("", "QUIT", connInfo.quitMessage);
sendMessage(quitCommand.encode());
/* TODO: I don't know how long we should wait here */
Thread.sleep(dur!("seconds")(1));
/* Tare down the client */
disconnect();
}
/**
* Tare down the client by setting the run state
* to false, closing the socket, stopping the
* receieve and send handlers and the event engine
*/
private void disconnect()
{
/* Set the state of running to false */
running = false;
logger.log("disconnect() begin");
/* Close the socket */
socket.close();
logger.log("disconnect() socket closed");
/* Wait for reeceive handler to realise it needs to stop */
recvHandler.join();
logger.log("disconnect() recvHandler stopped");
/* Wait for the send handler to realise it needs to stop */
sendHandler.join();
logger.log("disconnect() sendHandler stopped");
/* TODO: Stop eventy (FIXME: I don't know if this is implemented in Eventy yet, do this!) */
engine.shutdown();
logger.log("disconnect() eventy stopped");
logger.log("disconnect() end");
}
/**
* Called by the main loop thread to process the received
* and CRLF-delimited message
*
* Params:
* message = the message to add to the receive queue
*/
private void processMessage(ubyte[] message)
{
// import std.stdio;
// logger.log("Message length: "~to!(string)(message.length));
// logger.log("InterpAsString: "~cast(string)message);
receiveQ(message);
}
/**
* The main loop for the Client thread which receives data
* sent from the server
*/
private void loop()
{
/* TODO: We could do below but nah for now as we know max 512 bytes */
/* TODO: Make the read bulk size a configurable parameter */
/* TODO: Make static array allocation outside, instead of a dynamic one */
// ulong bulkReadSize = 20;
/* Fixed allocation of `bulkReadSize` for temporary data */
ubyte[] currentData;
currentData.length = connInfo.getBulkReadSize();
// malloc();
/* Total built message */
ubyte[] currentMessage;
bool hasCR = false;
/**
* Message loop
*
* FIXME: We need to find a way to tare down this socket, we don't
* want to block forever after running quit
*/
while(running)
{
/* Receieve at most 512 bytes (as per RFC) */
ptrdiff_t bytesRead = socket.receive(currentData, SocketFlags.PEEK);
import std.stdio;
// writeln(bytesRead);
// writeln(currentData);
/* FIXME: CHECK BYTES READ FOR SOCKET ERRORS! */
/* If we had a CR previously then now we need a LF */
if(hasCR)
{
/* First byte following it should be LF */
if(currentData[0] == 10)
{
/* Add to the message */
currentMessage~=currentData[0];
/* TODO: Process mesaage */
processMessage(currentMessage);
/* Reset state for next message */
currentMessage.length = 0;
hasCR=false;
/* Chop off the LF */
ubyte[] scratch;
scratch.length = 1;
this.socket.receive(scratch);
continue;
}
else
{
/* TODO: This is an error */
assert(false);
}
}
ulong pos;
for(pos = 0; pos < bytesRead; pos++)
{
/* Find first CR */
if(currentData[pos] == 13)
{
/* If we already have CR then that is an error */
if(hasCR)
{
/* TODO: Handle this */
assert(false);
}
hasCR = true;
break;
}
}
/* If we have a CR, then read up to that */
if(hasCR)
{
/* Read up to CR */
currentMessage~=currentData[0..pos+1];
/* Dequeue this (TODO: way to dispose without copy over) */
/* Guaranteed as we peeked this lenght */
ubyte[] scratch;
scratch.length = pos+1;
this.socket.receive(scratch);
continue;
}
/* Add whatever we have read to build-up */
currentMessage~=currentData[0..bytesRead];
/* TODO: Dequeue without peek after this */
ubyte[] scratch;
scratch.length = bytesRead;
this.socket.receive(scratch);
/* TODO: Yield here and in other places before continue */
}
}
unittest
{
/* FIXME: Get domaina name resolution support */
// ConnectionInfo connInfo = ConnectionInfo.newConnection("irc.freenode.net", 6667, "testBirchwood");
//freenode: 149.28.246.185
//snootnet: 178.62.125.123
//bonobonet: fd08:8441:e254::5
ConnectionInfo connInfo = ConnectionInfo.newConnection("irc.freenode.net", 6667, "testBirchwood");
Client client = new Client(connInfo);
client.connect();
import core.thread;
Thread.sleep(dur!("seconds")(2));
client.command(new Message("", "NICK", "birchwood"));
Thread.sleep(dur!("seconds")(2));
client.command(new Message("", "USER", "doggie doggie irc.frdeenode.net :Tristan B. Kildaire"));
Thread.sleep(dur!("seconds")(4));
client.command(new Message("", "JOIN", "#birchwoodtesting"));
Thread.sleep(dur!("seconds")(2));
client.command(new Message("", "NAMES", ""));
/* TODO: Add a check here to make sure the above worked I guess? */
/* TODO: Make this end */
// while(true)
// {
// }
// TODO: Don't forget to re-enable this when done testing!
Thread.sleep(dur!("seconds")(15));
client.quit();
}
}