mirror of
https://git.sr.ht/~emerson/reflectionircd
synced 2025-08-05 16:59:10 +00:00
285 lines
No EOL
12 KiB
TypeScript
285 lines
No EOL
12 KiB
TypeScript
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<string, string>
|
|
allCaps: Map<string, string>
|
|
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<string, string>) {
|
|
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<string, string> = 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();
|
|
}
|
|
} |