import axios from 'axios'; import { randomUUID } from 'crypto'; 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", ""], ["draft/channel-rename", ""], ["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); }); } checkIfRegistered(passedTags: Map = new Map()) { if (this.user === null) this.sendMessage(this.server.name, "451", [this.localNick, "You have not registered"], passedTags); return this.user !== null; } getMatrixUserFromNick(targetNick: string, passedTags: Map = new Map()) { if (!this.user) return false; const target = this.user.nickToMatrixUser.get(targetNick); if (!target) { this.sendMessage(this.server.name, "401", [this.user.nick, targetNick, "No such nick"], passedTags); return false; } return target; } getChannel(channel: string, passedTags: Map = new Map()) { if (!this.user) return false; const targetChannel = this.user.channels.get(channel); if (!targetChannel) { this.sendMessage(this.server.name, "403", [this.user.nick, channel, "No such channel"], passedTags); return false; } return targetChannel; } checkIfInChannel(channel: Channel, passedTags: Map = new Map()) { if (!this.user) return false; if (!this.user.channels.get(channel.name)) { this.sendMessage(this.server.name, "442", [this.user.nick, "You're not on that channel"], passedTags); return false; } return true; } checkMinParams(message: IRCMessage, neededNumber: number) { if (!this.user) return false; if (message.params.length < neededNumber) { this.sendMessage(this.server.name, "461", [this.user.nick, message.command, "Not enough parameters"], message.tags); return false; } return true; } 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(' '); } routeMessage(data: string) { const message = parseIRCMessage(data); switch (message.command.toUpperCase()) { case 'AUTHENTICATE': this.doAUTHENTICATE(message); break; case 'CAP': this.doCAP(message); break; case 'INVITE': this.doINVITE(message); break; case 'KICK': this.doKICK(message); break; case 'MODE': this.doMODE(message); break; case 'NAMES': this.doNAMES(message); break; case 'NOTICE': this.doMSG(message); break; case 'PART': this.doPART(message); break; case 'PING': this.sendMessage(this.server.name, "PONG", message.params, message.tags); break; case 'PRIVMSG': this.doMSG(message); break; case 'TAGMSG': this.doTAGMSG(message); break; case 'TOPIC': this.doTOPIC(message); break; case 'WHO': this.doWHO(message); break; } } 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 accessTokenAndServer = authArray[2]; const sepIndex = accessTokenAndServer.indexOf(":"); const accessToken = accessTokenAndServer.slice(0, sepIndex); const homeserver = accessTokenAndServer.slice(sepIndex+1); console.log(accessToken, homeserver); const thisIRCUser = this.server.getOrCreateIRCUser(mxid, accessToken, homeserver); 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://${this.user.homeserver}/_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']) { const joinedRooms: Set = new Set(); for (const roomId of Object.keys(rooms.join)) { const targetChannel = this.user.getOrCreateIRCChannel(roomId); joinedRooms.add(targetChannel); //@ts-ignore rooms.join[roomId].state.events.forEach((nextEvent: any) => this.user.routeMatrixEvent(nextEvent, targetChannel)); } joinedRooms.forEach(c => { if (this.user !== null) { this.user.channels.set(c.name, c); this.user.roomIdToChannel.set(c.roomId, c); } }); } 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); }).catch(function (error) { console.log(error); }) } } }) } } 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"); } } } } doINVITE(message: IRCMessage) { if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) return; const targetUser = this.getMatrixUserFromNick(message.params[0], message.tags); const targetChannel = this.getChannel(message.params[1], message.tags); if (!this.user || !targetUser || !targetChannel) return; if (!this.checkIfInChannel(targetChannel, message.tags)) return; if (targetChannel.matrixUsers.has(targetUser.nick)) { this.sendMessage(this.server.name, "443", [this.user.nick, targetUser.nick, "is already on channel"], message.tags); return; } const reason = (message.params.length === 3) ? message.params[2] : ""; const data = { "reason": reason, "user_id": targetUser.mxid } axios.post(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/invite?access_token=${this.user.accessToken}`, data).catch(function (error) { if (error.response) { console.log(error.response.data); } else if (error.request) { console.log(error.request); } else { console.log('Error', error.message); console.log(error.config); } }) } doKICK(message: IRCMessage) { if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) return; const targetChannel = this.getChannel(message.params[0], message.tags); const targetUser = this.getMatrixUserFromNick(message.params[1], message.tags); if (!this.user || !targetUser || !targetChannel) return; if (!this.checkIfInChannel(targetChannel, message.tags)) return; if (!targetChannel.matrixUsers.has(targetUser.nick)) { this.sendMessage(this.server.name, "441", [this.user.nick, targetUser.nick, targetChannel.name, "They aren't on that channel"], message.tags); return; } const reason = (message.params.length === 3) ? message.params[2] : ""; const data = { "reason": reason, "user_id": targetUser.mxid } axios.post(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/kick?access_token=${this.user.accessToken}`, data).catch(function (error) { if (error.response) { console.log(error.response.data); } else if (error.request) { console.log(error.request); } else { console.log('Error', error.message); console.log(error.config); } }) } doMODE(message: IRCMessage) { if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1) || !this.user) return; const targetChannel = this.user.channels.get(message.params[0]); if (!targetChannel) { if (message.params[0] !== this.user.nick) { this.sendMessage(this.server.name, "502", [this.user.nick, "Can't view mode for other users"], message.tags); return; } this.sendMessage(this.server.name, "221", [this.user.nick, "+i"], message.tags); return; } if (!this.checkIfInChannel(targetChannel, message.tags)) return; if (message.params.length === 1) { this.sendMessage(this.server.name, "324", [this.user.nick, targetChannel.name, `+n`], message.tags); return; } } doMSG(message: IRCMessage) { if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) return; const targetChannel = this.getChannel(message.params[0], message.tags); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel, message.tags)) return; let msgtype = 'm.text'; let msgbody = message.params[1]; if (message.command === 'NOTICE') msgtype = 'm.notice'; else if (message.params[1].startsWith('\x01')) { msgtype = 'm.emote'; msgbody = msgbody.replace(/\x01(ACTION\s)?/gi, ''); } const roomId = targetChannel.roomId; const content = { "body": msgbody, "msgtype": msgtype, "m.relates_to": {} } if (message.tags.has("+draft/reply")) { content["m.relates_to"] = { "m.in_reply_to": { "event_id": message.tags.get("+draft/reply") } } } const newTxnid = randomUUID(); this.user.txnIdStore.set(newTxnid, this); axios.put(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/send/m.room.message/${newTxnid}?access_token=${this.user.accessToken}`, content).catch(function (error) { if (error.response) { console.log(error.response.data); } else if (error.request) { console.log(error.request); } else { console.log('Error', error.message); console.log(error.config); } }); } sendNAMES(targetChannel: Channel, passedTags: Map = new Map()) { if (!this.user) return; let namesList: string[] = []; for (const matrixUser of targetChannel.matrixUsers.values()) { const opStatus = targetChannel.getNickPowerLevelMapping(matrixUser.nick); namesList.push(`${opStatus}${matrixUser.nick}`); } let singleNamesList: string[] = [] namesList.forEach((singleName, index) => { if (index === 0 || index % 20 !== 0) { singleNamesList.push(singleName); } else { //@ts-ignore this.sendMessage(this.server.name, "353", numerics["353"](this.user.nick, "=", targetChannel.name, singleNamesList), passedTags); singleNamesList = []; } }) if (singleNamesList.length !== 0) { this.sendMessage(this.server.name, "353", numerics["353"](this.user.nick, "=", targetChannel.name, singleNamesList), passedTags); } this.sendMessage(this.server.name, "366", numerics["366"](this.user.nick, targetChannel.name), passedTags); } doNAMES(message: IRCMessage) { if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0], message.tags); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel, message.tags)) return; this.sendNAMES(targetChannel); } doPART(message: IRCMessage) { if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0], message.tags); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel, message.tags)) return; const reason = (message.params.length === 2) ? message.params[1] : ""; axios.post(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/leave?access_token=${this.user.accessToken}`, {"reason": reason}).then(response => { if (response.status === 200) { //@ts-ignore this.user.getClients().forEach(c => { //@ts-ignore c.sendMessage(this.user.getMask(), "PART", [targetChannel.name, reason], message.tags); }) //@ts-ignore this.user.channels.delete(targetChannel.name); //@ts-ignore this.user.roomIdToChannel.delete(targetChannel.roomId); } else { //@ts-ignore this.sendMessage(this.server.name, "NOTICE", [this.user.nick, JSON.stringify(response.data)], message.tags); } }).catch(function (error) { if (error.response) { console.log(error.response.data); } else if (error.request) { console.log(error.request); } else { console.log('Error', error.message); console.log(error.config); } }) } doTAGMSG(message: IRCMessage) { if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0], message.tags); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel, message.tags)) return; if (message.tags.has("+draft/react") && message.tags.has("+draft/reply")) { const content = { "m.relates_to": { "event_id": message.tags.get("+draft/reply"), "key": message.tags.get("+draft/react"), "rel_type": "m.annotation" } } const newTxnid = randomUUID(); this.user.txnIdStore.set(newTxnid, this); axios.put(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/send/m.reaction/${newTxnid}?access_token=${this.user.accessToken}`, content).catch(function (error) { if (error.response) { console.log(error.response.data); } else if (error.request) { console.log(error.request); } else { console.log('Error', error.message); console.log(error.config); } }); } } sendTOPIC(targetChannel: Channel, passedTags: Map = new Map()) { if (!this.user) return; const topicText = targetChannel.topic.get('text') || ''; const topicSetter = targetChannel.topic.get('setter') || 'matrix'; const topicTimestamp = targetChannel.topic.get('timestamp') || '0'; if (topicText === '') { this.sendMessage(this.server.name, '331', [this.user.nick, targetChannel.name, 'No topic is set'], passedTags); return; } this.sendMessage(this.server.name, '332', [this.user.nick, targetChannel.name, topicText], passedTags); this.sendMessage(this.server.name, '333', [this.user.nick, targetChannel.name, topicSetter, topicTimestamp], passedTags); } doTOPIC(message: IRCMessage) { if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0], message.tags); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel, message.tags)) return; if (message.params.length === 1) { this.sendTOPIC(targetChannel, message.tags); return; } const topic = message.params[1]; axios.put(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/state/m.room.topic?access_token=${this.user.accessToken}`, {"topic": topic}).catch(function (error) { if (error.response) { console.log(error.response.data); } else if (error.request) { console.log(error.request); } else { console.log('Error', error.message); console.log(error.config); } }) } doWHO(message: IRCMessage) { if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0], message.tags); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel, message.tags)) return; for (const matrixUser of targetChannel.matrixUsers.values()) { const opStatus = targetChannel.getNickPowerLevelMapping(matrixUser.nick); const userParams = [ this.user.nick, targetChannel.name, matrixUser.ident, matrixUser.hostname, this.server.name, matrixUser.nick, `H${opStatus}`, `0 ${matrixUser.realname}` ] this.sendMessage(this.server.name, '352', userParams, message.tags); } this.sendMessage(this.server.name, '315', [this.user.nick, targetChannel.name, "End of /WHO"], 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(); } }