286 lines
7.2 KiB
TypeScript
286 lines
7.2 KiB
TypeScript
/**
|
|
* @file
|
|
* @copyright 2020, Valentin Anger
|
|
* @license ISC
|
|
*/
|
|
|
|
import { BufReader } from "https://deno.land/std@0.69.0/io/bufio.ts";
|
|
|
|
import IrcError from "./error.ts";
|
|
import Message from "./message.ts";
|
|
import { Replies } from "./replies.ts";
|
|
|
|
export interface ClientOptions {
|
|
nickname: string;
|
|
hostname: string;
|
|
port?: number;
|
|
use_tls?: boolean;
|
|
join_channels?: string[];
|
|
nickserv_password?: string;
|
|
}
|
|
|
|
export class Client {
|
|
readonly nickname: string;
|
|
readonly hostname: string;
|
|
readonly port: number;
|
|
readonly use_tls: boolean;
|
|
readonly join_channels: string[];
|
|
readonly nickserv_password?: string;
|
|
|
|
private conn?: Deno.Conn;
|
|
private buf_reader?: BufReader;
|
|
private encoder = new TextEncoder();
|
|
|
|
private message_buffer: Message[] = [];
|
|
|
|
/** Creates a new IRC Client
|
|
The default port is encrypted_port.
|
|
TLS is used by default.
|
|
*/
|
|
constructor(options: ClientOptions) {
|
|
this.nickname = options.nickname;
|
|
this.hostname = options.hostname;
|
|
this.port = options.port ?? Client.encrypted_port;
|
|
this.use_tls = options.use_tls ?? true;
|
|
this.join_channels = options.join_channels ?? [];
|
|
this.nickserv_password = options.nickserv_password;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
const connectFun = this.use_tls ? Deno.connectTls : Deno.connect;
|
|
const connection = await connectFun({
|
|
hostname: this.hostname,
|
|
port: this.port,
|
|
});
|
|
this.conn = connection;
|
|
this.buf_reader = new BufReader(connection, 512);
|
|
|
|
await this.sendMessage("NICK", [this.nickname]);
|
|
await this.sendMessage("USER", [this.nickname, "0", "*", "DenoIrc"]);
|
|
|
|
this.queueMessage(await this.getReply(Replies.RPL_WELCOME));
|
|
|
|
if (this.nickserv_password != undefined) {
|
|
this.nickserv(this.nickserv_password);
|
|
}
|
|
|
|
this.queueMessage(await this.getReply(Replies.RPL_ENDOFMOTD));
|
|
|
|
for (const channel of this.join_channels) {
|
|
await this.joinChannel(channel, true);
|
|
}
|
|
}
|
|
|
|
connected(): boolean {
|
|
return this.conn != undefined;
|
|
}
|
|
|
|
async disconnect(quit_message: string = ""): Promise<void> {
|
|
if (this.conn != undefined) {
|
|
await this.sendMessage("QUIT", [quit_message]);
|
|
}
|
|
|
|
await this.getReply("ERROR");
|
|
|
|
this.drop();
|
|
}
|
|
|
|
drop(): void {
|
|
if (this.conn != undefined) {
|
|
this.conn.close();
|
|
this.conn = undefined;
|
|
}
|
|
|
|
this.buf_reader = undefined;
|
|
}
|
|
|
|
async nickserv(password: string): Promise<void> {
|
|
return await this.sendPrivmsg("NickServ", `IDENTIFY ${password}`);
|
|
}
|
|
|
|
/** Constructs and sends a message as defined in RFC2812
|
|
Sections: 2.3
|
|
*/
|
|
async sendMessage(command: string, parameters: string[]): Promise<void> {
|
|
if (this.conn == undefined) {
|
|
throw new IrcError("Client is not connected");
|
|
}
|
|
|
|
let message = command;
|
|
|
|
let remaining = parameters.length;
|
|
for (const param of parameters) {
|
|
remaining -= 1;
|
|
|
|
// TODO Check parameter for validity
|
|
if (remaining > 0 || !message.match(/:| /)) {
|
|
// BNF middle
|
|
message += " " + param;
|
|
} else {
|
|
// BNF trailing
|
|
message += " :" + param;
|
|
}
|
|
}
|
|
|
|
// TODO Substitute all invalid chars with placeholders
|
|
|
|
message += "\r\n";
|
|
|
|
const encoded = this.encoder.encode(message);
|
|
if (encoded.length > 512) {
|
|
throw new IrcError("Resulting message is too long");
|
|
}
|
|
|
|
await this.conn.write(encoded).then((b) => {
|
|
if (b != encoded.length) {
|
|
throw new IrcError("Transmission failed");
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Reads the next IRC message */
|
|
async readMessage(): Promise<Message> {
|
|
if (this.message_buffer.length > 0) {
|
|
return this.message_buffer.shift() as Message;
|
|
}
|
|
|
|
return this.readMessageStream();
|
|
}
|
|
|
|
private async readMessageStream() {
|
|
if (this.buf_reader == undefined) {
|
|
throw new IrcError("Client is not connected");
|
|
}
|
|
|
|
const message = await this.buf_reader.readString("\n");
|
|
|
|
if (message === null) {
|
|
return Promise.reject(null);
|
|
}
|
|
|
|
return new Message(this, message);
|
|
}
|
|
|
|
private queueMessage(message: Message): void {
|
|
this.message_buffer.push(message);
|
|
}
|
|
|
|
private async getReply(...replies: string[]): Promise<Message> {
|
|
while (true) {
|
|
const message = await this.readMessageStream();
|
|
|
|
if (replies.includes(message.command)) {
|
|
return message;
|
|
} else if (message.command[0] == "4") {
|
|
throw new IrcError("Received error code", message.command);
|
|
} else {
|
|
if (message.command == "PING") {
|
|
// Keep connection alive
|
|
await this.sendMessage("PONG", message.parameters);
|
|
} else {
|
|
this.queueMessage(message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async sendPrivmsg(target: string, message: string): Promise<void> {
|
|
await this.sendMessage("PRIVMSG", [target, message]);
|
|
}
|
|
|
|
async sendNotice(target: string, message: string): Promise<void> {
|
|
await this.sendMessage("NOTICE", [target, message]);
|
|
}
|
|
|
|
async joinChannel(
|
|
name: string,
|
|
keep_message: boolean = false,
|
|
): Promise<void> {
|
|
await this.sendMessage("JOIN", [name]);
|
|
while (true) {
|
|
const join = await this.getReply("JOIN");
|
|
|
|
if (join.parameters[0] == name) {
|
|
if (keep_message) {
|
|
this.queueMessage(join);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
this.queueMessage(join);
|
|
}
|
|
}
|
|
|
|
async leaveChannel(name: string, part_message?: string): Promise<void> {
|
|
let parameters = [name];
|
|
if (part_message != undefined) {
|
|
parameters.push(part_message);
|
|
}
|
|
|
|
await this.sendMessage("PART", parameters);
|
|
}
|
|
|
|
/** Generates a stream of incomming messages
|
|
Pings and CTCP version requests are handled internally.
|
|
*/
|
|
messages(): AsyncIterable<Message> {
|
|
const client = this;
|
|
|
|
return {
|
|
async *[Symbol.asyncIterator](): AsyncIterator<Message> {
|
|
while (true) {
|
|
let message;
|
|
try {
|
|
message = await client.readMessage();
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (message.command[0] == "4") {
|
|
throw new IrcError("Recieved error code", message.command);
|
|
}
|
|
|
|
switch (message.command) {
|
|
case "ERROR":
|
|
client.drop();
|
|
yield message;
|
|
return;
|
|
case "PING":
|
|
await client.sendMessage("PONG", message.parameters);
|
|
break;
|
|
case "PRIVMSG":
|
|
// Respond to CTCP version requests
|
|
if (
|
|
message.sender != undefined &&
|
|
message.parameters[1] == "\u{1}VERSION\u{1}"
|
|
) {
|
|
await client.sendPrivmsg(
|
|
message.sender.nickname,
|
|
"\u{1}VERSION\u{1} " +
|
|
Client.client_name +
|
|
":" +
|
|
Client.client_version +
|
|
":" +
|
|
Client.client_environment,
|
|
);
|
|
break;
|
|
}
|
|
default:
|
|
yield message;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
export namespace Client {
|
|
export const unencrypted_port = 6667;
|
|
export const encrypted_port = 6697;
|
|
|
|
export const client_name = "DenoIrc";
|
|
export const client_version = "0.1.0";
|
|
export const client_environment = "undefined";
|
|
}
|