import axios from 'axios'; import { Socket } from 'net'; import { Channel } from './Channel.js'; import { IRCUser } from './IRCUser.js'; import { IRCMessage, parseIRCMessage } from './Message.js'; import numerics from './numerics.js'; import { Server } from './Server.js'; export class Client { user: IRCUser|null capVersion: string enabledCaps: Map allCaps: Map localNick: string localUsername: string localRealname: string deviceId: string constructor(private socket: Socket, public server: Server) { this.user = null; this.capVersion = '301'; this.enabledCaps = new Map(); this.allCaps = new Map([ ["account-tag", ""], ["batch", ""], ["draft/chathistory", ""], ["echo-message", ""], ["draft/event-playback", ""], ["extended-join", ""], ["invite-notify", ""], ["message-tags", ""], ["sasl", "PLAIN"], ["server-time", ""], ]); this.localNick = 'none'; this.localUsername = 'none'; this.localRealname = 'none'; this.deviceId = ""; this.socket.on('data', (data) => this.receiveData(data)); //this.socket.on('close', (e) => {if (this.user) this.user.handleClientClose(this, e)}); } receiveData(data: Buffer|String) { const dataArray = data.toString().split('\r\n'); dataArray.forEach(m => { const trimmedMsg = m.replace('\r', '').replace('\n', ''); if (trimmedMsg !== '') this.routeMessage(trimmedMsg); }); } routeMessage(data: string) { const message = parseIRCMessage(data); switch (message.command.toUpperCase()) { case 'AUTHENTICATE': { this.doAUTHENTICATE(message); break; } case 'CAP': { this.doCAP(message); break; } case 'MODE': { if (!this.user) { return; } const maybeChannel = this.user.channels.get(message.params[0]); if (maybeChannel) { maybeChannel.sendMode(this, message.tags); } break; } case 'NAMES': { if (!this.user) { return; } const maybeChannel = this.user.channels.get(message.params[0]); if (maybeChannel) { maybeChannel.sendNames(this, message.tags); } break; } case 'NOTICE': { if (this.user) { this.user.sendMessageToMatrix(message, this); } break; } case 'PING': { this.sendMessage(this.server.name, "PONG", message.params, message.tags); break; } case 'PRIVMSG': { if (this.user) { this.user.sendMessageToMatrix(message, this); } break; } case 'WHO': { if (!this.user) { return; } const maybeChannel = this.user.channels.get(message.params[0]); if (maybeChannel) { maybeChannel.sendWho(this, message.tags); } break; } } } getCapString(capVersion: string) { let capArray = []; for (const [key, value] of this.allCaps.entries()) { if (capVersion === '301' || value.length === 0) { capArray.push(key); } else { capArray.push(`${key}=${value}`); } } return capArray.join(' '); } doCAP(message: IRCMessage) { switch (message.params[0]) { case 'LS': { if (message.params.length === 2) { this.capVersion = message.params[1]; } this.sendMessage(this.server.name, "CAP", ["*", "LS", this.getCapString(this.capVersion)], message.tags); break; } case 'LIST': { this.sendMessage(this.server.name, "CAP", ["*", "LIST", this.getCapString(this.capVersion)], message.tags); break; } case 'REQ': { const capsToChange = (message.params[1].indexOf(' ') === -1) ? [message.params[1]] : message.params[1].split(' '); const capsEnabled: string[] = []; capsToChange.forEach(cap => { if (this.allCaps.has(cap)) { this.enabledCaps.set(cap, ''); capsEnabled.push(cap); } }); this.sendMessage(this.server.name, "CAP", ["*", "ACK", capsEnabled.join(' ')], message.tags); break; } case 'END': { if (this.user !== null) { this.doRegistration(message); } else { this.closeConnectionWithError("You must use SASL to connect to this server"); } } } } doAUTHENTICATE(message: IRCMessage) { if (message.params[0] === "PLAIN") { this.sendMessage("", "AUTHENTICATE", ["+"], message.tags); } else { const authArray = Buffer.from(message.params[0], 'base64').toString('utf-8').split('\0'); if (!authArray || authArray.length !== 3) { this.sendMessage(this.server.name, '904', numerics['904']('*'), message.tags) this.closeConnectionWithError('Invalid authentication') } const mxid = authArray[1]; const accessToken = authArray[2]; const thisIRCUser = this.server.getOrCreateIRCUser(mxid, accessToken); thisIRCUser.getVerification().then((response) => { if (response.status === 401 || response.status === 403) { this.sendMessage(this.server.name, '904', numerics['904']('*'), message.tags) this.closeConnectionWithError('Invalid authentication') } else if (response.status === 429) { this.sendMessage(this.server.name, '904', numerics['904']('*'), message.tags) this.closeConnectionWithError('rate limited, please try again later') } else if (response.status !== 200) { this.sendMessage(this.server.name, '904', numerics['904']('*'), message.tags) this.closeConnectionWithError('verification failed, please check credentials') } this.deviceId = response.data.device_id if (response.data.user_id !== mxid) { this.sendMessage(this.server.name, '904', numerics['904']('*'), message.tags) this.closeConnectionWithError('access token does not match mxid, please check credentials') } else { this.user = thisIRCUser; this.sendMessage(this.server.name, '900', numerics['900'](this.user.getMask(), this.user.nick), new Map()); this.sendMessage(this.server.name, '903', numerics['903'](this.user.nick), new Map()); if (this.user.isSynced()) { this.user.addClient(this, message.tags); } else { axios.get(`https://matrix.org/_matrix/client/v3/sync?access_token=${accessToken}`).then(response => { const data = response.data; const rooms = data.rooms; if (this.user === null) return; if (rooms['join']) { for (const roomId of Object.keys(rooms.join)) { const targetChannel = this.server.getOrCreateIRCChannel(roomId); this.user.channels.set(targetChannel.name, targetChannel); rooms.join[roomId].state.events.forEach((nextEvent: any) => targetChannel.routeMatrixEvent(nextEvent)); } } if (this.user === null) return; this.user.nextBatch = data.next_batch; this.sendMessage(this.server.name, 'NOTICE', [this.user.nick, 'You are now synced to the network!'], message.tags); this.user.addClient(this, message.tags); }) } } }) } } doRegistration(message: IRCMessage) { if (this.user === null) { this.closeConnectionWithError("You must use SASL to connect to this server"); return; } this.sendMessage(this.server.name, '001', numerics['001'](this.user.nick, this.server.name), message.tags); this.sendMessage(this.server.name, '002', numerics['002'](this.user.nick, this.server.name, '0.0.1'), message.tags); this.sendMessage(this.server.name, '003', numerics['003'](this.user.nick, 'yesterday'), message.tags); this.sendMessage(this.server.name, '004', numerics['004'](this.user.nick, this.server.name, '0.0.1', 'i', 'Lhionpsv'), message.tags); const iSupportArray = [ 'CASEMAPPING=ascii', 'CHANMODES=,,,Linps', 'CHANTYPES=#&!', 'MAXTARGETS=1', 'PREFIX=(ohv)@%+', ] if (this.enabledCaps.has('draft/chathistory')) { iSupportArray.push('CHATHISTORY=50'); } this.sendMessage(this.server.name, '005', numerics['005'](this.user.nick, iSupportArray), message.tags); this.sendMessage(this.server.name, '375', numerics['375'](this.user.nick), message.tags); this.sendMessage(this.server.name, '372', numerics['372'](this.user.nick, "It's an MOTD"), message.tags); this.sendMessage(this.server.name, '376', numerics['376'](this.user.nick), message.tags); this.sendMessage(this.user.nick, 'MODE', [this.user.nick, '+i'], message.tags); if (!this.user.isSynced()) this.sendMessage(this.server.name, 'NOTICE', [this.user.nick, 'Please wait for initial sync, this may take a while if you are in many large channels'], message.tags); } sendMessage(prefix: string, command: string, params: string[], tags: Map) { const capTagMapping = new Map([ ['account', 'account-tag'], ['label', 'labeled-response'], ['msgid', 'message-tags'], ['reflectionircd.chat/delete-message', 'reflectionircd.chat/delete-message'], ['reflectionircd.chat/edit-message', 'reflectionircd.chat/edit-message'], ['time', 'server-time'], ]) const ourTags: Map = new Map(); if (this.enabledCaps.has('server-time') && !tags.has('time')) ourTags.set('time', new Date().toISOString()); tags.forEach((v, k) => { if (k.startsWith('+')) { if (this.enabledCaps.has('message-tags')) { ourTags.set(k, v); } } else { const capToCheck = capTagMapping.get(k) || ''; if (this.enabledCaps.has(capToCheck)) { ourTags.set(k, v); } } }) const newMsg = new IRCMessage(ourTags, prefix, command, params); const msgToSend = newMsg.toString(); //console.log(`SENT: ${msgToSend}`); this.socket.write(`${msgToSend}\r\n`); } closeConnectionWithError(message: string) { this.sendMessage(this.server.name, 'ERROR', [message], new Map()); this.socket.destroy(); } }