/** * @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 { 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 { 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 { 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 { 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 { 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 { 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 { await this.sendMessage("PRIVMSG", [target, message]); } async sendNotice(target: string, message: string): Promise { await this.sendMessage("NOTICE", [target, message]); } async joinChannel( name: string, keep_message: boolean = false, ): Promise { 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 { 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 { const client = this; return { async *[Symbol.asyncIterator](): AsyncIterator { 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"; }