67P/hyperchannel

View on GitHub
app/services/coms.js

Summary

Maintainability
A
25 mins
Test Coverage
import Service, { inject as service } from '@ember/service';
import { isPresent, isEmpty } from '@ember/utils';
import { A } from '@ember/array';
import { tracked } from '@glimmer/tracking';
import IrcAccount from 'hyperchannel/models/account/irc';
import XmppAccount from 'hyperchannel/models/account/xmpp';
import Channel from 'hyperchannel/models/channel';
import Message from 'hyperchannel/models/message';
import config from 'hyperchannel/config/environment';

/**
 * This service provides the central command interface for communicating with
 * chat servers/networks
 * @class hyperchannel/services/coms
 */
export default class ComsService extends Service {

  // Utils
  @service logger;
  // Data storage
  @service('remotestorage') storage;
  // Message transport
  @service sockethub;
  @service('sockethub-irc') irc;
  @service('sockethub-xmpp') xmpp;

  /**
   * A collection of all account model instances
   * @type {Account[]}
   */
  @tracked accounts = A([]);
  /**
   * A collection of all channel instances
   * @type {Channel[] | UserChannel}
   */
  @tracked channels = A([]);

  get sortedChannels () {
    return this.channels.sortBy('name');
  }

  get channelDomains () {
    return this.channels.mapBy('domain').uniq().sort();
  }

  get groupedChannelsByDomain () {
    return this.channelDomains.map(domain => {
      return {
        domain: domain,
        channels: this.channels.filterBy('domain', domain).sortBy('name')
      };
    });
  }

  get activeChannel () {
    return this.channels.findBy('visible');
  }

  get onboardingComplete() {
    return isPresent(this.accounts);
  }

  /**
   * This is called from the application route on app startup. Sets up all
   * listeners for incoming Sockethub messages.
   * @public
   */
  setupListeners () {
    this.sockethub.client.socket.on('message', this.handleSockethubMessage.bind(this));
  }

  /**
   * This is called from the application route on app startup. Instantiates,
   * connects, and joins all configured/saved or default accounts and channels
   * @public
   */
  async instantiateAccountsAndChannels () {
    return new Promise((resolve, reject) => {
      this.storage.rs.kosmos.accounts.getIds().then(accountIds => {
        if (isEmpty(accountIds)) {
          console.debug('No accounts found in RS');
          resolve(); return;
        }
        console.debug('Loading account config for', accountIds);

        const allAccounts = accountIds.map(async id => {
          const config = await this.storage.rs.kosmos.accounts.getConfig(id);
          if (isEmpty(config)) {
            console.debug('RS: config missing for account', id);
            return;
          }

          const properties = {
            username: config.username, password: config.password,
            nickname: config.nickname, botkaURL: config.botkaURL,
            server: config.server
          }
          let account;
          switch(config.protocol) {
            case 'XMPP':
              account = new XmppAccount(properties);
              break;
            case 'IRC':
              account = new IrcAccount(properties);
              break;
          }

          this.connectServer(account);

          this.accounts.pushObject(account);

          // TODO wait for successful server connection before joining
          return this.instantiateChannels(account);
        });

        Promise.all(allAccounts).then(resolve);
      }, e => {
        this.log('error', 'Failed to load accounts from RS', e);
        reject();
      });
    });
  }

  /**
   * Invokes the connect function on the appropriate transport service
   * @public
   */
  connectServer (account) { // JID
    this.getServiceForSockethubPlatform(account.protocol)
        .connect(account);
  }

  /**
   * Invokes the channel-join function on the appropriate transport service
   * @param {Channel} channel
   * @param {String} type - Type of channel. Can be "room" or "person"
   * @public
   */
  joinChannel (channel, type) {
    this.getServiceForSockethubPlatform(channel.protocol)
        .join(channel, type);
  }

  /**
   * Invokes the send-message function on the appropriate transport service
   * @param {Channel} channel
   * @param {String} content
   * @param {String} [id]
   * @public
   */
  transferMessage (channel, message) {
    const target = {
      id: channel.sockethubChannelId,
      type: channel.isUserChannel ? 'person' : 'room',
      name: channel.name
    };
    this.getServiceForSockethubPlatform(channel.protocol)
        .transferMessage(target, message);
  }

  /**
   * Invokes the send-action-message function on the appropriate transport service
   * @public
   */
  transferMeMessage (account, target, content) {
    switch (account.protocol) {
      case 'XMPP':
        // TODO implement
        break;
      case 'IRC':
        this.irc.transferMeMessage(target, content);
        break;
    }
  }

  leaveChannel (channel) {
    this.getServiceForSockethubPlatform(channel.protocol)
        .leave(channel);
  }

  changeTopic (channel, topic) {
    switch (channel.protocol) {
      case 'XMPP':
        // TODO implement
        break;
      case 'IRC':
        this.irc.changeTopic(channel, topic);
        break;
    }
  }

  updateChannelUserList (message) {
    const channel = this.getChannel(message.actor.id);

    if (channel) {
      channel.connected = true;
      if (Array.isArray(message.object.members)) {
        channel.userList = message.object.members;
      }
    }
  }

  addUserToChannelUserList (message) {
    const channel = this.getChannel(message.target.id);
    if (channel) {
      channel.addUser(message.actor.name);
    }
  }

  removeUserFromChannelUserList (message) {
    // TODO handle user quit leaves (multiple channels)
    // e.g. target is `{ type: 'service', id: 'irc.freenode.net' }`
    const sockethubChannelId = typeof message.target === 'object' ? message.target.id : message.target;
    const channel = this.getChannel(sockethubChannelId);
    if (channel) {
      channel.removeUser(message.actor.name);
    }
  }

  /**
   * @param {String} channelId - a Sockethub channel ID
   */
  getChannel (channelId) {
    const channel = this.channels.findBy('sockethubChannelId', channelId);

    if (isEmpty(channel)) {
      console.warn('Could not find channel by sockethubChannelId', channelId);
      return;
    }

    return channel;
  }

  updateUsername (message) {
    if (typeof message.actor === 'object') {
      const actorId = message.actor.id;
      const account = this.accounts.findBy('sockethubPersonId', actorId);
      if (isPresent(account)) {
        account.updateUsername(message.target.name);
      }
      // TODO update nickname in channels
    }
  }

  updateChannelTopic (message) {
    let channel = this.getChannel(message.target.id);

    if (isEmpty(channel)) {
      console.warn('No channel for update topic message found.', message);
      return;
    }

    const currentTopic = channel.topic;
    const newTopic = message.object.topic;

    channel.topic = newTopic;

    if (isPresent(currentTopic) && (newTopic !== currentTopic) && !channel.visible) {
      Notification.requestPermission(function() {
        new Notification(channel.name, {
          body: `New Topic: ${newTopic}`
        });
      });
    }

    // TODO obsolete? if not, name a function for it
    // let notification = new Message({
    //   type: 'notification-topic-change',
    //   date: new Date(message.published),
    //   nickname: message.actor.name,
    //   content: message.object.topic
    // });
    // channel.messages.pushObject(notification);
  }

  async instantiateChannels (account) {
    const channelData = await this.storage.rs.kosmos.channels.getAll(account.id);

    for (const cid in channelData) {
      this.createChannel(account, channelData[cid].name, {
        isLogged: channelData[cid].isLogged
      });
    }
  }

  createChannel (account, channelName, options = {}) {
    const channel = new Channel({
      account: account,
      name: channelName,
      displayName: channelName,
      isLogged: options.isLogged
    });
    this.channels.pushObject(channel);
    this.joinChannel(channel, 'room');

    if (options.saveConfig) {
      this.storage.saveChannel(channel);
    }

    // TODO Check other message archives first, depending on protocol
    // (e.g. MAM for XMPP)
    if (account.protocol === 'IRC') {
      this.loadLastMessagesFromKosmosArchives(channel, { isLogged: options.isLogged });
    }

    return channel;
  }

  loadLastMessagesFromKosmosArchives (channel, options = {}) {
    // TODO implement a channel getter for the hostname so it works with
    // protocols other than IRC
    const channelNetworkHostname = channel.account.server.hostname;

    const discoverPublicLogs = !options.isLogged;
    const networkIsLogged    = config.publicLogs.knownLoggedNetworks.includes(channelNetworkHostname);

    if (discoverPublicLogs && !networkIsLogged) return false;

    return fetch(`${channel.publicLogsBaseUrl}/meta`).then(res => res.json()).then(meta => {
      channel.isLogged = true;
      return this.loadArchiveMessages(channel, meta.last);
    }).catch(() => {
      channel.isLogged = false;
    }).finally(() => {
      if (discoverPublicLogs) { this.storage.saveChannel(channel); }
    });
  }

  async loadArchiveMessages (channel, dateStr, options = {}) {
    options.minMessages = options.minMessages || 10;
    options.maxDays = options.maxDays || 5;

    let messagesCount = 0;
    let archivesCount = 0;

    while ((messagesCount < options.minMessages) &&
           (archivesCount < options.maxDays)) {
      const archive = await fetch(`${channel.publicLogsBaseUrl}/${dateStr}`).then(res => res.json());

      archive.today?.messages?.forEach(message => {
        let channelMessage = new Message({
          type: 'message-chat',
          date: new Date(message.timestamp),
          nickname: message.from,
          content: message.text,
          id: message.id
        });

        channel.addMessage(channelMessage);
      });

      messagesCount += archive.today?.messages?.length || 0;
      archivesCount += 1;

      dateStr = archive.today?.previous;
      channel.searchedPreviousLogsUntilDate = dateStr;
      if (isEmpty(dateStr)) break;
    }
  }

  createUserChannel (account, name) {
    const channel = this.getServiceForSockethubPlatform(account.protocol)
                        .createUserChannel(account, name);

    this.channels.pushObject(channel);
    return channel;
  }

  async removeChannel (channel) {
    this.leaveChannel(channel);
    this.channels.removeObject(channel);
    if (!channel.isUserChannel) {
      await this.storage.removeChannel(channel);
    }
    return;
  }

  async removeAccount (account) {
    const channels = this.channels.filterBy('account', account);
    console.debug(`Removing ${channels.length} channels before removing account:`, channels);
    for (const channel of channels) {
      await this.removeChannel(channel);
    }

    this.accounts.removeObject(account);
    await this.storage.removeAccount(account);
    return;
  }

  getServiceForSockethubPlatform (protocol) {
    return this[protocol.toLowerCase()];
  }

  /**
   * Handles incoming Sockethub messages:
   * - Attendance list for channel
   * - Another user joined or left a channel
   * - Received a channel message (normal or me/action)
   * - A channel topic was updated
   * - The username/address changed
   * @private
   */
  handleSockethubMessage (message) {
    this.log(`${message.context}_message`, 'SH message', message);

    if (message.actor.type === 'service') {
      this.log(`${message.context}_message`, 'skipping service message');
      return;
    }

    switch (message.type) {
      case 'query':
        if (message.object['type'] === 'attendance') {
          this.updateChannelUserList(message);
        }
        break;
      // TODO remove deprecated term in favor of query
      case 'observe':
        if (message.object['type'] === 'attendance') {
          this.updateChannelUserList(message);
        }
        break;
      case 'send':
        switch (message.object.type) {
          case 'message':
          case 'me':
            this.getServiceForSockethubPlatform(message.context)
                .addMessageToChannel(message);
            break;
        }
        break;
      case 'update':
        switch (message.object.type) {
          case 'topic':
            this.updateChannelTopic(message);
            break;
          case 'address':
            this.updateUsername(message);
            break;
          case 'presence':
            this.getServiceForSockethubPlatform(message.context)
                .handlePresenceUpdate(message)
            break;
          case 'error':
            console.warn('Received error update message', message.actor.id, message.object.content);
            break;
        }
        break;
    }
  }

  /**
   * Utility function for easier logging
   * @private
   */
  log () {
    this.logger.log(...arguments);
  }
}