server/backends/irc/controller.js

Summary

Maintainability
F
6 days
Test Coverage
#!/usr/bin/env node
//
//   Copyright 2009-2014 Ilkka Oksanen <iao@iki.fi>
//
//   Licensed under the Apache License, Version 2.0 (the "License");
//   you may not use this file except in compliance with the License.
//   You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
//   Unless required by applicable law or agreed to in writing,
//   software distributed under the License is distributed on an "AS
//   IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
//   express or implied.  See the License for the specific language
//   governing permissions and limitations under the License.
//

import redis from '../../lib/redis';
import UserGId from '../../lib/userGId';

const assert = require('assert');

const init = require('../../lib/init');

init.configureProcess('irc');

const conf = require('../../lib/conf');
const log = require('../../lib/log');
const courier = require('../../lib/courier').createEndPoint('ircparser');
const userIntroducer = require('../../lib/userIntroducer');
const User = require('../../models/user');
const IrcSubscription = require('../../models/ircSubscription');
const Conversation = require('../../models/conversation');
const ConversationMember = require('../../models/conversationMember');
const NetworkInfo = require('../../models/networkInfo');
const Window = require('../../models/window');
const conversationsService = require('../../services/conversations');
const windowsService = require('../../services/windows');
const nicksService = require('../../services/nicks');
const ircScheduler = require('./scheduler');

const OPER = '@';
const VOICE = '+';
const USER = 'u';

const ircMessageBuffer = {};

// Process different IRC commands

const handlers = {
  '043': handle043,
  311: handle311,
  312: handle312,
  317: handle317,
  319: handle319,
  332: handle332,
  333: handleNoop, // RPL_TOPICWHOTIME: No good place to show
  353: handle353,
  366: handle366,
  376: handle376or422,
  422: handle376or422, // No MOTD
  401: handle401,
  482: handle482,

  474: handleJoinReject, // ERR_BANNEDFROMCHAN
  473: handleJoinReject, // ERR_INVITEONLYCHAN
  475: handleJoinReject, // ERR_BADCHANNELKEY
  471: handleJoinReject, // ERR_CHANNELISFULL
  403: handleJoinReject, // ERR_NOSUCHCHANNEL
  405: handleJoinReject, // ERR_TOOMANYCHANNELS

  437: handleResourceUnavailable, // ERR_UNAVAILRESOURCE

  433: handle433, // ERR_NICKNAMEINUSE
  436: handle436, // ERR_NICKCOLLISION

  // All other numeric replies are processed by handleServerText()

  JOIN: handleJoin,
  PART: handlePart,
  QUIT: handleQuit,
  NICK: handleNick,
  MODE: handleMode,
  INVITE: handleInvite,
  KICK: handleKick,
  TOPIC: handleTopic,
  PRIVMSG: handlePrivmsg,
  NOTICE: handlePrivmsg,
  ERROR: handleError
};

init.on('beforeShutdown', async () => {
  await ircScheduler.quit();
  await courier.quit();
});

init.on('afterShutdown', () => {
  redis.shutdown();
  log.quit();
});

(async function main() {
  ircScheduler.init(disconnect);

  courier.on('send', processSend);
  courier.on('textCommand', processTextCommand);
  courier.on('join', processJoin);
  courier.on('close', processClose);
  courier.on('updatePassword', processUpdatePassword);
  courier.on('updateTopic', processUpdateTopic);
  courier.on('restarted', processRestarted);
  courier.on('data', processData);
  courier.on('noconnection', processNoConnection);
  courier.on('connected', processConnected);
  courier.on('disconnected', processDisconnected);
  courier.on('reconnectifinactive', processReconnectIfInactive);

  await courier.listen();
})();

// Upper layer messages

async function processSend({ conversationId, userId, text = '' }) {
  assert(conversationId);

  let target;
  const conversation = await Conversation.fetch(conversationId);
  const user = await User.fetch(userId);
  const network = conversation.get('network');

  if (!conversation) {
    return;
  }

  if (conversation.get('type') === '1on1') {
    const peerMember = await conversationsService.getPeerMember(conversation, user.gId);
    const targetUserGId = UserGId.create(peerMember.get('userGId'));

    target = await nicksService.getNick(targetUserGId, network);
  } else {
    target = conversation.get('name');
  }

  // See addMessageUnlessDuplicate() for the rest of duplication detection logic
  const key = `irc:duplicates_check:${conversation.id}:${msgFingerPrint(text)}:${user.gIdString}`;
  await redis.setex(key, 45, user.id);

  sendPrivmsg(user, network, target, text.replace(/\n/g, ' '));
}

async function processTextCommand({ conversationId, userId, command, commandParams }) {
  const conversation = await Conversation.fetch(conversationId);
  let data = false;

  switch (command) {
    case 'part':
      return { status: 'ERROR', errorMsg: 'Use the window menu instead to leave.' };
    case 'msg':
      return { status: 'ERROR', errorMsg: 'Use /ircquery <nick> to start 1on1 via IRC' };
    case 'me':
      data = `PRIVMSG ${conversation.get('name')} :\u0001ACTION ${commandParams}\u0001`;
      break;
    case 'topic':
      data = `TOPIC ${conversation.get('name')} :${commandParams}`;
      break;
    default:
      break;
  }

  if (!data) {
    data = `${command} ${commandParams}`;
  }

  courier.callNoWait('connectionmanager', 'write', {
    userId,
    network: conversation.get('network'),
    line: data
  });

  return { status: 'OK' };
}

async function processJoin({ userId, network, name, password }) {
  const user = await User.fetch(userId);
  const networkInfo = await findOrCreateNetworkInfo(user, network);
  const hasChannelPrefixRegex = /^[&#!+]/;
  const illegalNameRegEx = /\s|\cG|,/; // See RFC2812, section 1.3
  let channelName = name;

  if (!channelName || channelName === '' || illegalNameRegEx.test(channelName)) {
    return { status: 'ERROR', errorMsg: 'Illegal or missing channel name.' };
  }

  if (!hasChannelPrefixRegex.test(channelName)) {
    channelName = `#${channelName}`;
  }

  await IrcSubscription.create({
    userId: user.id,
    network,
    channel: channelName,
    password
  });

  if (networkInfo.get('state') === 'connected') {
    sendJoin(user, network, channelName, password);
  } else if (networkInfo.get('state') !== 'connecting') {
    await connect(user, network);
  }

  return { status: 'OK' };
}

async function processClose({ userId, network, name, conversationType }) {
  const user = await User.fetch(userId);

  if (conversationType === 'group') {
    const subscription = await IrcSubscription.findFirst({
      userId: user.id,
      network,
      channel: name
    });

    subscription.delete();

    const networkInfo = await findOrCreateNetworkInfo(user, network);

    if (networkInfo.get('state') === 'connected') {
      sendIRCPart(user, network, name);
    }
  }

  await disconnectIfIdle(user, network);
}

async function processUpdatePassword({ userId, conversationId, password }) {
  const user = await User.fetch(userId);
  const conversation = await Conversation.fetch(conversationId);
  const networkInfo = await findOrCreateNetworkInfo(user, conversation.get('network'));

  if (networkInfo.get('state') !== 'connected') {
    return {
      status: 'ERROR',
      errorMsg: "Can't change the password. You are not connected to the IRC network"
    };
  }

  courier.callNoWait('connectionmanager', 'write', {
    userId: user.id,
    network: conversation.get('network'),
    line: `MODE ${conversation.get('name')} ${password === '' ? '-k foobar' : `+k ${password}`}`
  });

  return { status: 'OK' };
}

async function processUpdateTopic({ userId, conversationId, topic }) {
  const user = await User.fetch(userId);
  const conversation = await Conversation.fetch(conversationId);
  const networkInfo = await findOrCreateNetworkInfo(user, conversation.get('network'));

  if (networkInfo.get('state') !== 'connected') {
    return {
      status: 'ERROR',
      errorMsg: "Can't change the topic. You are not connected to the IRC network"
    };
  }

  courier.callNoWait('connectionmanager', 'write', {
    userId,
    network: conversation.get('network'),
    line: `TOPIC ${conversation.get('name')} :${topic}`
  });

  return { status: 'OK' };
}

async function processReconnectIfInactive({ userId }) {
  const user = await User.fetch(userId);
  const networks = Object.keys(conf.get('irc:networks'));

  for (const network of networks) {
    const networkInfo = await findOrCreateNetworkInfo(user, network);

    if (networkInfo.get('state') === 'idledisconnected') {
      await addSystemMessage(
        user,
        network,
        'info',
        "You were disconnected from IRC because you haven't used MAS for a long time. " +
          'Welcome back! Reconnecting...'
      );

      await connect(user, network);
    }
  }
}

// Connection manager messages

// Restarted
async function processRestarted() {
  await iterateUsersAndNetworks(async (user, network) => {
    // Try makes sure problems with one user don't affect others
    try {
      const subscriptions = await IrcSubscription.find({
        userId: user.id,
        network
      });

      const networkInfo = await findOrCreateNetworkInfo(user, network);

      if (subscriptions.length > 0 && networkInfo.get('state') !== 'idledisconnected') {
        log.info(user, `Scheduling connect() to IRC network: ${network}`);

        await addSystemMessage(
          user,
          network,
          'info',
          'MAS Server restarted. Global rate limiting to avoid flooding IRC ' +
            ' server enabled. Next connect will be slow.'
        );

        await connect(user, network);
      }
    } catch (e) {
      log.warn(user, `Exception: ${e}, stack: ${e.stack.replace(/\n/g, ',')}`);
    }
  });
}

async function iterateUsersAndNetworks(callback) {
  const networks = Object.keys(conf.get('irc:networks'));
  const allUsers = await User.fetchAll();

  if (networks.length === 0) {
    log.error('No IRC networks configured.');
  }

  for (const user of allUsers) {
    for (const network of networks) {
      await callback(user, network);
    }
  }
}

// Data
async function processData(params) {
  const key = params.userId + params.network;

  if (!ircMessageBuffer[key]) {
    ircMessageBuffer[key] = [];
  }

  ircMessageBuffer[key].push(params);

  // Don't process multiple protocol messages from single connection in parallel.
  // IRC messages must me processed in the same order they arrive. Especially
  // response 353 and 366 would race with bad results without this limit.

  if (ircMessageBuffer[key].length === 1) {
    // Works because this function is reentrant!
    while (ircMessageBuffer[key].length > 0) {
      await parseIrcMessage(ircMessageBuffer[key][0]);
      ircMessageBuffer[key].shift();
    }
  }
}

// No connection
async function processNoConnection({ userId, network }) {
  const user = await User.fetch(userId);

  log.warn(user, 'Disconnected user tried to send IRC command');

  await addSystemMessage(user, network, 'error', "Can't send. Not connected to IRC currently.");
}

// Connected
async function processConnected({ userId, network }) {
  const user = await User.fetch(userId);

  log.info(user, 'Connected to IRC server');

  const commands = [`NICK ${user.get('nick')}`, `USER ${user.get('nick')} 8 * :${user.get('name')} (MAS v2.0)`];

  courier.callNoWait('connectionmanager', 'write', { userId: user.id, network, line: commands });
}

// Disconnected
async function processDisconnected({ userId, network, reason }) {
  const user = await User.fetch(userId);
  const networkInfo = await findOrCreateNetworkInfo(user, network);
  const previousState = networkInfo.get('state');

  await networkInfo.set('state', previousState === 'idleclosing' ? 'idledisconnected' : 'disconnected');

  await nicksService.updateUserNick(user, network, null);

  if (previousState === 'closing' || previousState === 'idleclosing') {
    // We wanted to close the connection, don't reconnect
    return;
  }

  let delay = 30 * 1000; // 30s
  let msg = `Lost connection to IRC server (${reason}). Scheduling a reconnect attempt...`;

  const count = networkInfo.get('retryCount') + 1;

  await networkInfo.set('retryCount', count);

  // Set the backoff timer
  if (count > 3 && count < 8) {
    delay = 3 * 60 * 1000; // 3 mins
  } else if (count >= 8) {
    delay = 60 * 60 * 1000; // 1 hour
    msg =
      'Error in connection to IRC server after multiple attempts. Waiting one hour ' +
      'before making another connection attempt. Close all windows related to this ' +
      'IRC network if you do not wish to retry.';
  }

  await addSystemMessage(user, network, 'error', msg);
  await connect(user, network, true, delay);
}

async function parseIrcMessage({ userId, line, network }) {
  const parts = line.trim().split(' ');
  const msg = {
    params: [],
    numericReply: false,
    network,
    serverName: null,
    nick: null,
    userNameAndHost: null
  };

  // See rfc2812

  if (line.charAt(0) === ':') {
    // Prefix exists
    const prefix = parts.shift();
    const nickEnds = prefix.indexOf('!');
    const identEnds = prefix.indexOf('@');

    if (nickEnds === -1 && identEnds === -1) {
      msg.serverName = prefix.substring(1);
    } else {
      msg.nick = prefix.substring(1, Math.min(nickEnds, identEnds));
      msg.userNameAndHost = prefix.substring(Math.min(nickEnds + 1, identEnds + 1));
    }
  }

  msg.command = parts.shift();

  if (msg.command.match(/^[0-9]+$/) !== null) {
    // Numeric reply
    msg.numericReply = true;
    msg.target = parts.shift();

    if (/^[&#!+]/.test(msg.target)) {
      // Channel names are case insensitive, always use lower case version
      msg.target = msg.target.toLowerCase();
    }
  }

  // Only the parameters are left now
  while (parts.length !== 0) {
    if (parts[0].charAt(0) === ':') {
      msg.params.push(parts.join(' ').substring(1));
      break;
    } else {
      msg.params.push(parts.shift());
    }
  }

  let handler = handlers[msg.command];

  if (!handler && msg.numericReply) {
    // Default handler for all numeric replies
    handler = handleServerText;
  }

  if (handler) {
    const user = await User.fetch(userId);
    await handler(user, msg, msg.command);
  }
}

async function addSystemMessage(user, network, cat, body) {
  const serverUserGId = UserGId.create({ type: 'irc', id: 0 });
  const conversation = await conversationsService.findOrCreate1on1(user, serverUserGId, network);

  await conversationsService.addMessage(conversation, { userGId: 'i0', cat, body });
}

async function connect(user, network, skipRetryCountReset, delay) {
  const networkInfo = await findOrCreateNetworkInfo(user, network);
  await nicksService.updateUserNick(user, network, user.get('nick'));

  await networkInfo.set('state', 'connecting');

  if (!skipRetryCountReset) {
    await networkInfo.set('retryCount', 0);
  }

  const delayText = delay ? ` in ${Math.round(delay / 1000 / 60)} minutes` : '';

  await addSystemMessage(user, network, 'info', `Connecting to IRC server${delayText}...`);

  ircMessageBuffer[user.id + network] = [];

  courier.callNoWait('connectionmanager', 'connect', {
    userId: user.id,
    nick: user.get('nick'),
    network,
    delay: delay || 0
  });
}

async function disconnect(user, network, nextState) {
  const networkInfo = await findOrCreateNetworkInfo(user, network);
  await networkInfo.set('state', nextState || 'closing');

  courier.callNoWait('connectionmanager', 'disconnect', {
    userId: user.id,
    network,
    reason: 'No open discussions left.'
  });
}

async function handleNoop() {
  // eslint-disable-line no-empty-function
}

async function handleServerText(user, msg, code) {
  // :mas.example.org 001 toyni :Welcome to the MAS IRC toyni
  const text = msg.params.join(' ');
  // 371, 372 and 375 = MOTD and INFO lines
  const cat = code === '372' || code === '375' || code === '371' ? 'banner' : 'server';

  if (text) {
    await addSystemMessage(user, msg.network, cat, text);
  }
}

async function handle401(user, msg) {
  // :irc.localhost 401 ilkka dadaa :No such nick/channel
  const targetUserGId = await nicksService.getUserGId(msg.params[0], msg.network);
  const conversation = await conversationsService.findOrCreate1on1(user, targetUserGId, msg.network);

  await conversationsService.addMessage(conversation, {
    userGId: 'i0',
    cat: 'error',
    body: `${msg.params[0]} is not in IRC.`
  });
}

async function handle043(user, msg) {
  // :*.pl 043 AnDy 0PNEAKPLG :nickname collision, forcing nick change to your unique ID.
  const newNick = msg.params[0];
  const oldNick = msg.target;

  await updateNick(user, msg.network, oldNick, newNick);
  await tryDifferentNick(user, msg.network);
}

async function handle311(user, msg) {
  // :irc.localhost 311 ilkka_ Mika7 ~Mika7 127.0.0.1 * :Real Name (Ralph v1.0)
  const nick = msg.params[0];
  const username = msg.params[1];
  const host = msg.params[2];
  const realName = msg.params[4];

  await addSystemMessage(user, msg.network, 'server', `--- ${nick} is [${username}@${host}] (${realName})`);
}

async function handle312(user, msg) {
  // :irc.localhost 312 ilkka_ Mika7 irc.localhost :Darwin ircd default configuration
  const server = msg.params[1];
  const serverInfo = msg.params[2];

  await addSystemMessage(user, msg.network, 'server', `--- using server ${server} [${serverInfo}]`);
}

async function handle317(user, msg) {
  // irc.localhost 317 ilkka_ Mika7 44082 1428703143 :seconds idle, signon time
  const idleTime = msg.params[1];
  const signonTime = new Date(parseInt(msg.params[2]) * 1000).toUTCString();

  await addSystemMessage(user, msg.network, 'server', `--- has been idle ${idleTime} seconds. Signed on ${signonTime}`);
}

async function handle319(user, msg) {
  // :irc.localhost 319 ilkka_ Mika7 :#portaali @#hemmot @#ilves #ceeassa
  const channels = msg.params[1];

  await addSystemMessage(user, msg.network, 'server', `--- on channels ${channels}`);
}

async function handle332(user, msg) {
  // :portaali.org 332 ilkka #portaali :Cool topic
  const channel = msg.params[0];
  const topic = msg.params[1];
  const conversation = await Conversation.findFirst({
    type: 'group',
    name: channel,
    network: msg.network
  });

  if (conversation) {
    await conversationsService.setTopic(conversation, topic, msg.target);
  }
}

async function handle353(user, msg) {
  // :own.freenode.net 353 drwillie @ #evergreenproject :drwillie ilkkaoks
  const channel = msg.params[1];
  const namesPart = msg.params[2];

  if (namesPart === '') {
    // Some servers send empty 353 RPL_NAMREPLY
    return;
  }

  const conversation = await Conversation.findFirst({
    type: 'group',
    name: channel,
    network: msg.network
  });

  if (conversation) {
    await bufferNames(namesPart.split(' '), user, msg.network, conversation);
  }
}

async function handle366(user, msg) {
  // :pratchett.freenode.net 366 il3kkaoksWEB #testi1 :End of /NAMES list.
  const channel = msg.params[0];
  const conversation = await Conversation.findFirst({
    type: 'group',
    name: channel,
    network: msg.network
  });

  if (!conversation) {
    return;
  }

  const key = `irc:names_buffer:${user.id}:${conversation.id}`;
  const namesHash = await redis.hgetall(key);

  await redis.del(key);

  if (namesHash && Object.keys(namesHash).length > 0) {
    // During the server boot-up or reconnect after a network outage it's very possible that
    // 366 replies get reordered if more than one mas user joins a same channel. Then an
    // older 366 reply (with fewer names on the list) is used to the reset the group members
    // data structure, which leads to incorrect bookkeeping. Ircnamesreporter check makes sure
    // only one 266 reply is parsed from a burst. For rest of the changes we rely on getting
    // incremental JOINS messages (preferably from the original reporter.) This leaves some
    // theoretical error edge cases (left as homework) that maybe are worth fixing.
    const noActiveReporter = await redis.setnx(`irc:names_reporter:${conversation.id}`, user.id);

    if (noActiveReporter) {
      await redis.expire(`irc:names_reporter:${conversation.id}`, 15); // 15s
      await conversationsService.setGroupMembers(conversation, namesHash);
    }
  }
}

async function handle376or422(user, msg) {
  const networkInfo = await findOrCreateNetworkInfo(user, msg.network);

  await addSystemMessage(user, msg.network, 'server', msg.params.join(' '));

  if (networkInfo.get('state') !== 'connected') {
    await networkInfo.set({
      state: 'connected',
      retryCount: 0
    });

    await addSystemMessage(user, msg.network, 'info', 'Connected to IRC server.');
    await addSystemMessage(
      user,
      msg.network,
      'info',
      "You can close this window at any time. It'll reappear when needed."
    );

    // Tell the client nick we got
    await userIntroducer.introduce(user, user.gId);

    const subscriptions = await IrcSubscription.find({
      userId: user.id,
      network: msg.network
    });

    if (!subscriptions) {
      log.info(user, 'Connected, but no channels/1on1s to join. Disconnecting');
      await disconnect(user, msg.network);
      return;
    }

    subscriptions.forEach(subscription => {
      sendJoin(user, msg.network, subscription.get('channel'), subscription.get('password'));
    });
  }
}

async function handle482(user, msg) {
  // irc.localhost 482 ilkka #test2 :You're not channel operator
  const channel = msg.params[0];

  await addSystemMessage(user, msg.network, 'error', `You're not channel operator on ${channel}`);
}

async function handleJoin(user, msg) {
  // :neo!i=ilkkao@iao.iki.fi JOIN :#testi4
  const channel = msg.params[0];
  const network = msg.network;
  const targetUser = await nicksService.getUser(msg.nick, network);
  let conversation = await Conversation.findFirst({
    type: 'group',
    name: channel,
    network
  });

  if (targetUser) {
    // MAS user joined channel
    let subscription = await IrcSubscription.findFirst({
      userId: targetUser.id,
      network,
      channel
    });

    if (!subscription) {
      // IrcSubscription entry is missing. This means IRC server has added the user
      // to a channel without any action from the user. IRC channel subscription must be
      // added as it's used to rejoin channels after a server restart.
      subscription = await IrcSubscription.create({
        userId: targetUser.id,
        network,
        channel,
        password: null
      });
    }

    const password = subscription.get('password');

    if (!conversation) {
      conversation = await Conversation.create({
        owner: targetUser.id,
        type: 'group',
        name: channel,
        password,
        network
      });

      log.info(targetUser, `First mas user joined channel: ${network}:${channel}`);
    }

    const window = Window.findFirst({ userId: targetUser.id, conversationId: conversation.id });

    if (!window) {
      await windowsService.create(targetUser, conversation);
      await conversationsService.sendFullAddMembers(conversation, targetUser);
    }

    if (password) {
      // Conversation exists and this user used non empty password successfully to join
      // the channel. Update conversation password as it's possible that all other
      // mas users were locked out during a server downtime and conversation.password is
      // out of date.
      await conversation.set('password', password);
    }
  }

  const userGId = await nicksService.getUserGId(msg.nick, msg.network);
  await conversationsService.addGroupMember(conversation, userGId, 'u');
}

async function handleJoinReject(user, msg) {
  const channel = msg.params[0];
  const reason = msg.params[1];

  const subscription = await IrcSubscription.findFirst({
    userId: user.id,
    network: msg.network,
    channel
  });

  if (subscription) {
    await subscription.delete();
  }

  await addSystemMessage(
    user,
    msg.network,
    'error',
    subscription ? `Failed to join ${channel}, reason: ${reason}` : `${reason}: ${channel}`
  );

  const conversation = await Conversation.findFirst({
    type: 'group',
    name: channel,
    network: msg.network
  });

  if (conversation) {
    await conversationsService.removeGroupMember(conversation, user.gId);
  }

  await disconnectIfIdle(user, msg.network);
}

async function handleResourceUnavailable(user, msg) {
  // irc.localhost 482 ilkka ilkka :Nick/channel is temporarily unavailable"
  const networkInfo = await findOrCreateNetworkInfo(user, msg.network);
  const channelOrNick = msg.params[0];
  const currentNick = await nicksService.getNick(user.gId, msg.network);

  if (channelOrNick === currentNick) {
    if (networkInfo.get('state') !== 'connected') {
      await tryDifferentNick(user, msg.network);
    } else {
      await addSystemMessage(user, msg.network, 'info', msg.params.join(' '));
    }
  } else {
    await handleJoinReject(user, msg);
  }
}

async function handle433(user, msg) {
  const networkInfo = await findOrCreateNetworkInfo(user, msg.network);

  if (networkInfo.get('state') !== 'connected') {
    await tryDifferentNick(user, msg.network);
  } else {
    await addSystemMessage(user, msg.network, 'info', `ERROR: Nick ${msg.params[0]} is already taken.`);
  }
}

async function handle436(user, msg) {
  await tryDifferentNick(user, msg.network);
}

async function handleQuit(user, msg) {
  // :ilkka!ilkkao@localhost.myrootshell.com QUIT :"leaving"
  // let reason = msg.params[0];
  const conversations = await conversationsService.getAll(user);

  for (const conversation of conversations) {
    // TODO: Send a real quit message instead of part
    if (conversation.get('network') === msg.network && conversation.get('type') === 'group') {
      // No need to check if the targetUser is on this channel,
      // removeGroupMember() is clever enough
      const userGId = await nicksService.getUserGId(msg.nick, msg.network);
      await conversationsService.removeGroupMember(conversation, userGId);
    }
  }
}

async function handleNick(user, msg) {
  // :ilkkao!~ilkkao@localhost NICK :foobar
  const newNick = msg.params[0];
  const oldNick = msg.nick;

  await updateNick(user, msg.network, oldNick, newNick);
}

async function handleError(user, msg) {
  const reason = msg.params[0];

  await addSystemMessage(user, msg.network, 'error', `Connection lost. Server reason: ${reason}`);

  if (reason.includes('Too many host connections')) {
    log.warn(user, `Too many connections to: ${msg.network}`);

    await addSystemMessage(
      user,
      msg.network,
      'error',
      `${msg.network} IRC network doesn't allow more connections. Close all windows related to this IRC network and rejoin another day to try again.`
    ); // eslint-disable-line max-len
  }
}

async function handleInvite(user, msg) {
  // :ilkkao!~ilkkao@127.0.0.1 INVITE buppe :#test2
  const channel = msg.params[1];

  await addSystemMessage(user, msg.network, 'info', `${msg.nick} has invited you to channel ${channel}`);
}

async function handleKick(user, msg) {
  // :ilkkao!~ilkkao@127.0.0.1 KICK #portaali AnDy :no reason
  const channel = msg.params[0];
  const targetNick = msg.params[1];
  const reason = msg.params[2];

  const conversation = await Conversation.findFirst({
    type: 'group',
    name: channel,
    network: msg.network
  });

  const targetUserGId = await nicksService.getUserGId(targetNick, msg.network);

  if (conversation) {
    await conversationsService.removeGroupMember(conversation, targetUserGId, { wasKicked: true, reason });
  }

  if (targetUserGId.equals(user.gId)) {
    // I was kicked
    await addSystemMessage(user, msg.network, 'error', `You have been kicked from ${channel}, Reason: ${reason}`);

    await disconnectIfIdle(user, msg.network);
  }
}

async function handlePart(user, msg) {
  // :ilkka!ilkkao@localhost.myrootshell.com PART #portaali :
  const channel = msg.params[0];
  const reason = msg.params[1];

  const conversation = await Conversation.findFirst({
    type: 'group',
    name: channel,
    network: msg.network
  });

  const targetUserGId = await nicksService.getUserGId(msg.nick, msg.network);

  if (conversation) {
    await conversationsService.removeGroupMember(conversation, targetUserGId, { reason });
  }
}

async function handleMode(user, msg) {
  // :ilkka9!~ilkka9@localhost.myrootshell.com MODE #sunnuntai +k foobar3
  const target = msg.params[0];

  if (!isChannel(target)) {
    // TDB: Handle user's mode change
    return;
  }

  const conversation = await Conversation.findFirst({
    type: 'group',
    name: target,
    network: msg.network
  });

  if (!conversation) {
    return;
  }

  await addMessageUnlessDuplicate(conversation, user, {
    cat: 'info',
    body: `Mode change: ${msg.params.join(' ')} by ${msg.nick ? msg.nick : msg.serverName}`
  });

  const modeParams = msg.params.slice(1);

  while (modeParams.length !== 0) {
    const command = modeParams.shift();
    const oper = command.charAt(0);
    const modes = command.substring(1).split('');

    if (!(oper === '+' || oper === '-')) {
      log.warn(user, `Received broken MODE command: ${command}`);
      continue;
    }

    for (const mode of modes) {
      let param;
      let newClass = null;
      let targetUserGId = null;

      if (mode.match(/[klbeIORovq]/)) {
        param = modeParams.shift();

        if (!param) {
          log.warn(user, `Received broken MODE command, parameter for ${mode} missing`);
          continue;
        } else if (mode.match(/[ov]/)) {
          targetUserGId = await nicksService.getUserGId(param, msg.network);
        }
      }

      if (mode === 'o' && oper === '+') {
        // Got oper status
        newClass = OPER;
      } else if (mode === 'o' && oper === '-') {
        // Lost oper status
        newClass = USER;
      } else if (mode === 'v') {
        const oldClass = await conversationsService.getMemberRole(conversation, targetUserGId);

        if (oldClass !== OPER) {
          if (oper === '+') {
            // Non-oper got voice
            newClass = VOICE;
          } else {
            // Non-oper lost voice
            newClass = USER;
          }
        }
      } else if (mode === 'k') {
        const newPassword = oper === '+' ? param : null;

        await conversation.set('password', newPassword);

        const subscription = await IrcSubscription.findFirst({
          userId: user.id,
          network: msg.network,
          channel: target
        });

        await subscription.set('password', newPassword);
      }

      if (newClass) {
        await conversationsService.updateMemberRole(conversation, targetUserGId, newClass);
      }
    }
  }
}

async function handleTopic(user, msg) {
  // :ilkka!ilkkao@localhost.myrootshell.com TOPIC #portaali :My new topic
  const channel = msg.params[0];
  const topic = msg.params[1];
  const conversation = await Conversation.findFirst({
    type: 'group',
    name: channel,
    network: msg.network
  });

  if (conversation) {
    await conversationsService.setTopic(conversation, topic, msg.nick);
  }
}

async function handlePrivmsg(user, msg, command) {
  // :ilkkao!~ilkkao@127.0.0.1 NOTICE buppe :foo
  const target = msg.params[0];
  const currentNick = await nicksService.getNick(user.gId, msg.network);
  let conversation;
  let sourceUserGId;
  let text = msg.params[1];
  let cat = 'msg';

  if (msg.nick) {
    sourceUserGId = await nicksService.getUserGId(msg.nick, msg.network);
  } else {
    // Message is from the server if the nick is missing
    sourceUserGId = UserGId.create({ type: 'irc', id: 0 });
  }

  if (text.includes('\u0001') && command === 'PRIVMSG') {
    const ret = parseCTCPMessage(text);
    let reply = false;

    if (ret.type === 'ACTION') {
      cat = 'action';
      text = ret.data;
    } else if (ret.type === 'VERSION') {
      reply = '\u0001VERSION masclient:0.8.0:Linux\u0001';
    } else if (ret.type === 'PING') {
      reply = text;
    }

    if (reply) {
      courier.callNoWait('connectionmanager', 'write', {
        userId: user.id,
        network: msg.network,
        line: `NOTICE ${msg.nick} :${reply}`
      });
      return;
    }
  }

  if (target.toLowerCase() === currentNick.toLowerCase()) {
    // Message is for the user only
    conversation = await conversationsService.findOrCreate1on1(user, sourceUserGId, msg.network);
  } else {
    conversation = await Conversation.findFirst({
      type: 'group',
      name: target,
      network: msg.network
    });

    if (conversation === null) {
      // :verne.freenode.net NOTICE * :*** Got Ident response
      await addSystemMessage(user, msg.network, 'info', text);
      return;
    }
  }

  await addMessageUnlessDuplicate(conversation, user, {
    userGId: sourceUserGId.toString(),
    cat,
    body: text
  });
}

async function updateNick(user, network, oldNick, newNick) {
  let changed = false;
  let targetUserGId = null;
  const nickUser = await nicksService.getUser(oldNick, network);

  if (nickUser) {
    const networkInfo = await findOrCreateNetworkInfo(nickUser, network);
    const updatedProps = await networkInfo.set('nick', newNick);

    if (updatedProps === 1) {
      changed = true;
      targetUserGId = user.gId;
    }
  } else {
    changed = await redis.set(`irc:nick_changed_mutex:${network}:${oldNick}:${newNick}`, 1, 'ex', 20, 'nx');
    targetUserGId = await nicksService.getUserGId(oldNick, network);
  }

  if (!changed) {
    return;
  }

  // We haven't heard about this change before
  log.info(user, `I'm first and announcing ${oldNick} -> ${newNick} nick change.`);

  const conversationMembers = await ConversationMember.find({ userGId: targetUserGId.toString() });

  const informedUserIds = {};

  // Iterate through the conversations that the nick changer is part of
  for (const conversationMember of conversationMembers) {
    const conversation = await Conversation.fetch(conversationMember.get('conversationId'));

    if (conversation.get('network') !== network) {
      continue;
    }

    // Some 1on1 are closed (no window), don't activate them
    if (conversation.get('type') !== '1on1') {
      await addMessageUnlessDuplicate(conversation, user, {
        cat: 'info',
        body: `${oldNick} is now known as ${newNick}`
      });
    }

    if (targetUserGId.isMASUser) {
      const members = await ConversationMember.find({ conversationId: conversation.id });

      members.forEach(member => {
        const userGId = UserGId.create(member.get('userGId'));

        if (userGId.isMASUser) {
          informedUserIds[userGId.id] = true;
        }
      });
    } else {
      await conversationsService.removeGroupMember(conversation, conversationMember.gId, { silent: true });

      await conversationsService.addGroupMember(
        conversation,
        await nicksService.getUserGId(newNick, network),
        conversationMember.get('role'),
        { silent: true }
      );
    }
  }

  if (targetUserGId.isMASUser) {
    for (const informedUserId of Object.keys(informedUserIds)) {
      const informedUser = await User.fetch(parseInt(informedUserId));

      await userIntroducer.introduce(informedUser, targetUserGId);
    }
  }
}

async function tryDifferentNick(user, network) {
  const nick = user.get('nick');

  // TODO: Temporary logging
  log.info(user, `tryDifferentNick: ${user.gId} and ${network}`);

  let currentNick = await nicksService.getNick(user.gId, network);

  // TODO: Temporary work-a-round until the bug is fixed
  if (!currentNick) {
    currentNick = 'masuser';
  }

  if (nick !== currentNick.substring(0, nick.length)) {
    // Current nick is unique ID, let's try to change it to something unique immediately
    currentNick = nick + (100 + Math.floor(Math.random() * 900));
  } else if (currentNick === nick) {
    // Second best choice
    currentNick = `${nick}_`;
  } else if (currentNick === `${nick}_`) {
    // Third best choice
    currentNick = nick + Math.floor(Math.random() * 10);
  } else {
    // If all else fails, keep adding random numbers
    currentNick += Math.floor(Math.random() * 10);
  }

  await nicksService.updateUserNick(user, network, currentNick);

  courier.callNoWait('connectionmanager', 'write', {
    userId: user.id,
    network,
    line: `NICK ${currentNick}`
  });
}

async function addMessageUnlessDuplicate(conversation, user, { userGId = null, cat, body = '' }) {
  // The code below works because IRC server doesn't echo messages back to their senders.
  // If that wasn't the case, the logic would fail. (If a reporter sees a new identical message
  // it's not considered as duplicate. Somebody is just repeating their line.)
  const key = `irc:duplicates_check:${conversation.id}:${msgFingerPrint(body)}:${userGId}`;
  const reporter = user.id;

  const [setexResult, getResult] = await redis.multi().setnx(key, reporter).get(key).exec();

  if (setexResult[1] === 1) {
    // the key was set
    await redis.expire(key, 45);
  } else if (parseInt(getResult[1]) !== user.id) {
    return; // duplicate;
  }

  await conversationsService.addMessage(conversation, { userGId, cat, body });
}

// TODO: Add a timer (every 15min?) to send one NAMES to every irc channel to make sure memberslist
// is in sync?

async function disconnectIfIdle(user, network) {
  const windows = await windowsService.getWindowsForNetwork(user, network);
  let onlyServer1on1Left = false;

  if (windows.length === 1) {
    // There's only one window left, is it IRC server 1on1?
    // If yes, we can disconnect from the server
    const lastConversation = await Conversation.fetch(windows[0].get('conversationId'));

    if (lastConversation.get('type') === '1on1') {
      const peerMember = await conversationsService.getPeerMember(lastConversation, user.gId);

      if (peerMember.gIdString === 'i0') {
        onlyServer1on1Left = true;
      }
    }
  }

  if (windows.length === 0 || onlyServer1on1Left) {
    await disconnect(user, network);
  }

  if (onlyServer1on1Left) {
    await addSystemMessage(user, network, 'info', 'No open windows left for this network. Disconnected.');
  }
}

async function bufferNames(names, user, network, conversation) {
  const namesHash = {};

  for (let nick of names) {
    let userClass;

    switch (nick.charAt(0)) {
      case '@':
        userClass = OPER;
        break;
      case '+':
        userClass = VOICE;
        break;
      default:
        userClass = USER;
        break;
    }

    if (userClass === OPER || userClass === VOICE) {
      nick = nick.substring(1);
    }

    const memberUserGId = await nicksService.getUserGId(nick, network);
    namesHash[memberUserGId.toString()] = userClass;
  }

  const key = `irc:names_buffer:${user.id}:${conversation.id}`;

  await redis.hmset(key, namesHash);
  await redis.expire(key, 60); // 1 minute. Does cleanup if we never get End of NAMES list reply.
}

function parseCTCPMessage(text) {
  // Follow http://www.irchelp.org/irchelp/rfc/ctcpspec.html
  const regex = /\u0001(.*?)\u0001/g; // eslint-disable-line no-control-regex
  let matches;

  // eslint-disable-next-line no-cond-assign
  while ((matches = regex.exec(text)) !== null) {
    const msg = matches[1];
    let dataType;
    let payload = '';

    if (msg.includes(' ')) {
      dataType = msg.substr(0, msg.indexOf(' '));
      payload = msg.substr(msg.indexOf(' ') + 1);
    } else {
      dataType = msg;
    }

    // Only one CTCP extended message per PRIVMSG is supported for now
    return { type: dataType || 'UNKNOWN', data: payload };
  }

  return { type: 'UNKNOWN' };
}

function isChannel(text) {
  return ['&', '#', '+', '!'].some(element => element === text.charAt(0));
}

function sendPrivmsg(user, network, target, text) {
  courier.callNoWait('connectionmanager', 'write', {
    userId: user.id,
    network,
    line: `PRIVMSG ${target} :${text}`
  });
}

function sendJoin(user, network, channel, password) {
  courier.callNoWait('connectionmanager', 'write', {
    userId: user.id,
    network,
    line: `JOIN ${channel} ${password}`
  });
}

function sendIRCPart(user, network, channel) {
  courier.callNoWait('connectionmanager', 'write', {
    userId: user.id,
    network,
    line: `PART ${channel}`
  });
}

async function findOrCreateNetworkInfo(user, network) {
  let networkInfo = await NetworkInfo.findFirst({
    userId: user.id,
    network
  });

  if (!networkInfo) {
    networkInfo = await NetworkInfo.create({
      userId: user.id,
      network,
      nick: user.get('nick'),
      state: 'disconnected',
      retryCount: 0
    });

    assert(networkInfo.valid);
  }

  return networkInfo;
}

function msgFingerPrint(body) {
  return body
    .trim()
    .substring(0, 100)
    .replace(/[\n\r]/g, ' ');
}