import { Axios } from 'axios'; import { randomUUID } from 'crypto'; import { Socket } from 'net'; import { Batch } from './Batch.js'; import { Channel } from './Channel.js'; import { MatrixUser } from './MatrixUser.js'; import { IRCMessage, parseIRCMessage } from './Message.js'; import { Server } from './Server.js'; export class Client { capVersion: string enabledCaps: Map allCaps: Map user: MatrixUser isRegistered: boolean apiCall: Axios batchesInProgress: Map constructor(private socket: Socket, public server: Server) { this.capVersion = '301'; this.enabledCaps = new Map(); this.allCaps = new Map([ ["account-tag", ""], ["batch", ""], ["draft/channel-rename", ""], ["echo-message", ""], ["reflectionircd.chat/edit-message", ""], ["reflectionircd.chat/extended-invite", ""], ["extended-join", ""], ["invite-notify", ""], ["labeled-response", ""], ["message-tags", ""], ["sasl", "PLAIN"], ["server-time", ""], ]); this.user = this.server.ourMatrixUser; this.isRegistered = false; this.apiCall = this.server.apiCall; this.batchesInProgress = new Map(); this.server.doLog("New client connected"); this.socket.on('data', (data) => this.receiveData(data)); //this.socket.on('close', (e) => {if (this.user) this.user.handleClientClose(this, e)}); } checkIfRegistered() { return this.isRegistered; } 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); }); } getMatrixUserFromNick(targetNick: string) { const target = this.server.nickToMatrixUser.get(targetNick); if (!target) { this.sendMessage(this.server.name, "401", [this.user.nick, targetNick, "No such nick"]); return false; } return target; } getChannel(channel: string) { const targetChannel = this.server.channels.get(channel); if (!targetChannel) { this.sendMessage(this.server.name, "403", [this.user.nick, channel, "No such channel"]); return false; } return targetChannel; } checkIfInChannel(channel: Channel) { if (!this.server.channels.get(channel.name)) { this.sendMessage(this.server.name, "442", [this.user.nick, "You're not on that channel"]); return false; } return true; } checkMinParams(message: IRCMessage, neededNumber: number) { if (message.params.length < neededNumber) { this.sendMessage(this.server.name, "461", [this.user.nick, message.command, "Not enough parameters"]); 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); const maybeBatchRef = message.tags.get('batch'); if (maybeBatchRef) { const maybeBatch = this.batchesInProgress.get(maybeBatchRef); if (maybeBatch) { maybeBatch.messages.add(message); } return; } switch (message.command.toUpperCase()) { case 'AUTHENTICATE': this.doAUTHENTICATE(message); break; case 'CAP': this.doCAP(message); break; case 'DELETEMSG': this.doDELETEMSG(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", ["+"]); } 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', ['*', "SASL Authentication failed"]) this.closeConnectionWithError('Invalid authentication') } if (authArray[2] === this.server.config.SASLPassword) { this.sendMessage(this.server.name, '900', [this.user.nick, this.server.getMask(), this.user.accountName, `You are now logged in as ${this.user.nick}`]); this.sendMessage(this.server.name, '903', [this.user.nick, "SASL authentication successful"]); this.isRegistered = true; } } } doBATCH(message: IRCMessage) { const referenceTag = message.params[0].substring(1); if (message.params[0].startsWith('+')) { this.batchesInProgress.set(referenceTag, new Batch(referenceTag, message)) } else if (message.params[0].startsWith('-')) { const readyBatch = this.batchesInProgress.get(referenceTag); if (readyBatch) { switch (readyBatch.batchType) { case 'draft/multiline': case 'multiline': this.doMultiline(readyBatch); break; default: break; } } this.batchesInProgress.delete(referenceTag); } } 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)]); break; } case 'LIST': { this.sendMessage(this.server.name, "CAP", ["*", "LIST", this.getCapString(this.capVersion)]); 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(' ')]); break; } case 'END': { if (this.isRegistered) { this.doRegistration(message); } else { this.closeConnectionWithError("You must use SASL to connect to this server"); } } } } doDELETEMSG(message: IRCMessage) { if (!this.checkIfRegistered() || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0]); const eventId = message.tags.get("reflectionircd.chat/delete-message"); if (!this.user || !targetChannel || !eventId) return; if (!this.checkIfInChannel(targetChannel)) return; const data = { "reason": (message.params.length === 2) ? message.params[1] : "" } const newTxnid = randomUUID(); this.apiCall.put(`/rooms/${targetChannel.roomId}/redact/${eventId}/${newTxnid}`, data).then(r => { const maybeEventID = r.data["event_id"]; if (maybeEventID) { this.server.eventIdStore.set(maybeEventID, this); const maybeLabel = message.tags.get("label") || ""; if (maybeLabel !== "") { this.server.eventIDToLabel.set(maybeEventID, maybeLabel) } } }).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); } }) } doINVITE(message: IRCMessage) { if (!this.checkIfRegistered() || !this.checkMinParams(message, 2)) return; const targetUser = this.getMatrixUserFromNick(message.params[0]); const targetChannel = this.getChannel(message.params[1]); if (!this.user || !targetUser || !targetChannel) return; if (!this.checkIfInChannel(targetChannel)) return; if (targetChannel.matrixUsers.has(targetUser.nick)) { this.sendMessage(this.server.name, "443", [this.user.nick, targetUser.nick, "is already on channel"]); return; } const reason = (message.params.length === 3) ? message.params[2] : ""; const data = { "reason": reason, "user_id": targetUser.mxid } this.apiCall.post(`/rooms/${targetChannel.roomId}/invite`, data).then(r => { const maybeEventID = r.data["event_id"]; if (maybeEventID) { this.server.eventIdStore.set(maybeEventID, this); const maybeLabel = message.tags.get("label") || ""; if (maybeLabel !== "") { this.server.eventIDToLabel.set(maybeEventID, maybeLabel) } } }).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() || !this.checkMinParams(message, 2)) return; const targetChannel = this.getChannel(message.params[0]); const targetUser = this.getMatrixUserFromNick(message.params[1]); if (!this.user || !targetUser || !targetChannel) return; if (!this.checkIfInChannel(targetChannel)) 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"]); return; } const reason = (message.params.length === 3) ? message.params[2] : ""; const data = { "reason": reason, "user_id": targetUser.mxid } this.apiCall.post(`/rooms/${targetChannel.roomId}/kick`, data).then(r => { const maybeEventID = r.data["event_id"]; if (maybeEventID) { this.server.eventIdStore.set(maybeEventID, this); const maybeLabel = message.tags.get("label") || ""; if (maybeLabel !== "") { this.server.eventIDToLabel.set(maybeEventID, maybeLabel) } } }).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() || !this.checkMinParams(message, 1) || !this.user) return; const targetChannel = this.server.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"]); return; } this.sendMessage(this.server.name, "221", [this.user.nick, "+i"]); return; } if (!this.checkIfInChannel(targetChannel)) return; if (message.params.length === 1) { const chanModes = [...targetChannel.channelModes.keys()].sort().join(''); this.sendMessage(this.server.name, "324", [this.user.nick, targetChannel.name, `+${chanModes}`]); return; } } doMultiline(batch: Batch) { } doMSG(message: IRCMessage) { if (!this.checkIfRegistered() || !this.checkMinParams(message, 2)) return; const targetChannel = this.getChannel(message.params[0]); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel)) 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 highlightFilteredMsg = msgbody.split(" ").map(w => { if (!w.startsWith('@')) return w; const endingCharMatch = w.match(/[,:]$/); const endingChar = (endingCharMatch) ? endingCharMatch[0] : ""; const endingCharIndex = (endingCharMatch) ? endingCharMatch.index : 0; const nickToSearch = (endingCharIndex === 0) ? w.substring(1) : w.substring(1, endingCharIndex); const maybeHighlight = targetChannel.matrixUsers.get(nickToSearch); return (maybeHighlight) ? `${maybeHighlight.mxid}${endingChar}` : w; }) const content = { "body": highlightFilteredMsg.join(" "), "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.apiCall.put(`/rooms/${targetChannel.roomId}/send/m.room.message/${newTxnid}`, content).then(r => { const maybeEventID = r.data["event_id"]; if (maybeEventID) { this.server.eventIdStore.set(maybeEventID, this); const maybeLabel = message.tags.get("label") || ""; if (maybeLabel !== "") { this.server.eventIDToLabel.set(maybeEventID, maybeLabel) } } }).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) { 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 { this.sendMessage(this.server.name, "353", [this.user.nick, "=", targetChannel.name, `${singleNamesList.join(' ')}`]); singleNamesList = []; } }) if (singleNamesList.length !== 0) { this.sendMessage(this.server.name, "353", [this.user.nick, "=", targetChannel.name, `${singleNamesList.join(' ')}`]); } this.sendMessage(this.server.name, "366", [this.user.nick, targetChannel.name, "End of /NAMES list"]); } doNAMES(message: IRCMessage) { if (!this.checkIfRegistered() || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0]); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel)) return; this.sendNAMES(targetChannel); } doPART(message: IRCMessage) { if (!this.checkIfRegistered() || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0]); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel)) return; const reason = (message.params.length === 2) ? message.params[1] : ""; this.apiCall.post(`/rooms/${targetChannel.roomId}/leave`, {"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]); }) //@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)]); } }).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() || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0]); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel)) 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.apiCall.put(`/rooms/${targetChannel.roomId}/send/m.reaction/${newTxnid}`, content).then(r => { const maybeEventID = r.data["event_id"]; if (maybeEventID) { this.server.eventIdStore.set(maybeEventID, this); const maybeLabel = message.tags.get("label") || ""; if (maybeLabel !== "") { this.server.eventIDToLabel.set(maybeEventID, maybeLabel) } } }).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) { 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']); return; } this.sendMessage(this.server.name, '332', [this.user.nick, targetChannel.name, topicText]); this.sendMessage(this.server.name, '333', [this.user.nick, targetChannel.name, topicSetter, topicTimestamp]); } doTOPIC(message: IRCMessage) { if (!this.checkIfRegistered() || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0]); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel)) return; if (message.params.length === 1) { this.sendTOPIC(targetChannel); return; } const topic = message.params[1]; this.apiCall.put(`/rooms/${targetChannel.roomId}/state/m.room.topic`, {"topic": topic}).then(r => { const maybeEventID = r.data["event_id"]; if (maybeEventID) { this.server.eventIdStore.set(maybeEventID, this); const maybeLabel = message.tags.get("label") || ""; if (maybeLabel !== "") { this.server.eventIDToLabel.set(maybeEventID, maybeLabel) } } }).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() || !this.checkMinParams(message, 1)) return; const targetChannel = this.getChannel(message.params[0]); if (!this.user || !targetChannel) return; if (!this.checkIfInChannel(targetChannel)) 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); } this.sendMessage(this.server.name, '315', [this.user.nick, targetChannel.name, "End of /WHO"]); } 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', [this.user.nick, `Welcome to the ${this.server.name} network, ${this.user.nick}`]) this.sendMessage(this.server.name, '002', [this.user.nick, `Your host is ${this.server.name}, running version 0.1.0`]); this.sendMessage(this.server.name, '003', [this.user.nick, `This server was created yesterday`]); this.sendMessage(this.server.name, '004', [this.user.nick, this.server.name, '0.1.0', 'i', 'hnouv']); const iSupportArray = [ 'CASEMAPPING=ascii', 'CHANMODES=,,,nu', 'CHANTYPES=#&!', 'MAXTARGETS=1', 'MODES=1', 'PREFIX=(ohv)@%+', ] if (this.enabledCaps.has('draft/chathistory')) { iSupportArray.push('CHATHISTORY=50'); } this.sendMessage(this.server.name, '005', [this.user.nick, ...iSupportArray, 'are supported by this server']); this.sendMessage(this.server.name, '375', [this.user.nick, "- Start of MOTD"]); this.sendMessage(this.server.name, '372', [this.user.nick, "It's an MOTD"]); this.sendMessage(this.server.name, '376', [this.user.nick, "- End of MOTD"]); this.sendMessage(this.user.nick, 'MODE', [this.user.nick, '+i']); this.server.addClient(this); } sendMessage(prefix: string, command: string, params: string[], tags: Map = new Map()) { const capTagMapping = new Map([ ['account', 'account-tag'], ['label', 'labeled-response'], ['msgid', 'message-tags'], ['reflectionircd.chat/delete-message', 'reflectionircd.chat/edit-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(); } }