Source

Revoice.js

const { API } = require("revolt-api");
const Signaling = require("./Signaling.js");
const EventEmitter = require("events");
const { Device, useSdesMid, RTCRtpCodecParameters } = require("msc-node");

/**
 * @class
 * @classdesc Operates media sources and users in voice channels
 */
class VoiceConnection extends EventEmitter {
  constructor(channelId, voice, opts) {
    super();
    this.voice = voice;
    this.channelId = channelId;

    this.users = [];

    this.device = opts.device;
    this.signaling = opts.signaling;
    this.setupSignaling();
    this.signaling.connect(channelId);

    this.leaveTimeout = opts.leaveOnEmpty;
    this.leaving; // the actual timeout cancellable

    this.media = null;
  }

  updateState(state) {
    this.state = state;
    this.emit("state", state);
  }

  /**
   * @description Get all the users associated with this voice connection
   *
   * @return {User[]} An array containing all the User objects
   */
  getUsers() {
    return this.signaling.users;
  }

  /**
   * @description Check if a user is connected to this voice channel
   *
   * @param  {string} userId The id of the user
   * @return {boolean}        Wether the user is in the voice channel
   */
  isConnected(userId) {
    return this.signaling.isConnected(userId);
  }

  setupSignaling() {
    const signaling = this.signaling;
    signaling.on("token", () => {});
    signaling.on("authenticate", (data) => {
      this.device.load({ routerRtpCapabilities: data.data.rtpCapabilities });
    });
    signaling.on("initTransports", (data) => {
      this.initTransports(data);
    });

    // user events
    signaling.on("roomfetched", () => {
      this.initLeave();
      signaling.users.forEach((user) => {
        this.voice.users.set(user.id, user);
        this.users.push(user);
      });
      this.emit("roomfetched");
    });
    signaling.on("userjoin", (user) => {
      this.voice.users.set(user.id, user);
      this.users.push(user);
      if (this.leaving) {
        clearTimeout(this.leaving);
        this.leaving = null;
      }
      this.emit("userjoin", user);
    });
    signaling.on("userleave", (user) => {
      this.resetUser(user);
      const idx = this.users.findIndex(u => u.id == user.id);
      if (idx !== -1) this.users.splice(idx, 1);
      this.initLeave();
      this.emit("userleave", user);
    });
  }
  initLeave() {
    const signaling = this.signaling;
    if (this.leaving) {
      clearTimeout(this.leaving);
      this.leaving = null;
    }
    if (!(signaling.roomEmpty && this.leaveTimeout)) return;
    this.leaving = setTimeout(() => {
      this.once("leave", () => {
        this.destroy();
        this.emit("autoleave");
      });
      this.leave();
    }, this.leaveTimeout * 1000);
  }
  initTransports(data) {
    this.sendTransport = this.device.createSendTransport({...data.data.sendTransport});
    this.sendTransport.on("connect", ({ dtlsParameters }, callback) => {
      this.signaling.connectTransport(this.sendTransport.id, dtlsParameters).then(callback);
    });
    this.sendTransport.on("produce", (parameters, callback) => {
      this.signaling.startProduce("audio", parameters.rtpParameters).then((cid) => {
        callback({ cid });
      });
    });

    this.updateState(Revoice.State.IDLE);
    this.emit("join");
  }
  resetUser(user) {
    this.emit("userLeave", user);
  }

  /**
   * @description Attach a Media object to this connection
   *
   * @example
   * const connection = voice.getVoiceConnection("someChannelId");
   * const player = new MediaPlayer();
   * connection.play(player);
   *
   * player.playFile("./audio.mp3");
   *
   * @param  {(Media|MediaPlayer)} media The media object that should be attached
   * @return {void}
   */
  async play(media) {
    this.updateState(((!media.isMediaPlayer) ? Revoice.State.UNKNOWN : Revoice.State.BUFFERING));

    media.on("finish", () => {
      this.signaling.stopProduce();
      this.producer.close();
      this.updateState(Revoice.State.IDLE);
    });
    media.on("buffer", (producer) => {
      this.producer = producer;
      this.updateState(Revoice.State.BUFFERING);
    });
    media.on("start", () => {
      this.updateState(Revoice.State.PLAYING);
    });
    media.on("pause", () => {
      this.updateState(Revoice.State.PAUSED);
    });
    this.media = media;
    this.media.transport = this.sendTransport;
    return this.producer;
  }
  closeTransport() {
    return new Promise((res) => {
      this.sendTransport.once("close", () => {
        this.sendTransport = undefined;
        res();
      });
      this.sendTransport.close();
    });
  }
  disconnect() {
    return new Promise((res) => {
      this.signaling.disconnect();
      this.closeTransport().then(() => {
        // just a temporary fix till vortex rewrite
      });
      this.device = Revoice.createDevice();
      res();
    });
  }
  destroy() {
    return new Promise(async (res) => {
      this.disconnect();
      if (this.media) await this.media.destroy();
      res();
    });
  }

  /**
   * @description Leave the voice channel
   * @async
   * @return {void}
   */
  async leave() {
    this.users.forEach(u => this.resetUser(u))
    this.updateState(Revoice.State.OFFLINE);
    await this.disconnect();
    if (this.media) this.media.disconnect();
    this.emit("leave");
  }
}


/**
 * Login information required, when you want to use a user account and not a bot. Please note that an account with MFA will not work.
 * @typedef {Object} Login
 * @property {String} email The email of the account.
 * @property {String} password The password of the account.
 */
/**
 * revolt-api configuration object. May be used for self-hosted revolt instances. @see {@link https://github.com/insertish/oapi#example} The last example for further information.
 * @typedef {Object} APIConfig
 * @property {String} baseURL The base url of the api of your revolt instance
 */
/**
 * @class
 * @classdesc The main class used to join channels and initiate voice connections
 * @augments EventEmitter
 */
class Revoice extends EventEmitter {
  static createDevice() {
    return new Device({
      headerExtensions: {
        audio: [
          useSdesMid(),
        ]
      },
      codecs: {
        audio: [
          new RTCRtpCodecParameters({
            mimeType: "audio/opus",
            clockRate: 48000,
            payloadType: 100,
            channels: 2
          })
        ]
      }
    });
  }
  static State =  {
    OFFLINE: "off", // not joined anywhere
    IDLE: "idle", // joined, but not playing
    BUFFERING: "buffer", // joined, buffering data
    PLAYING: "playing", // joined and playing
    PAUSED: "paused", // joined and paused
    JOINING: "joining", // join process active
    UNKNOWN: "unknown" // online but a Media instance is used to play audio
  }
  static Error = {
    ALREADY_CONNECTED: "acon", // joining failed because already connected to a voice channel in this server
    NOT_A_VC: "novc", // joining failed because the bot is already connected to the channel
    VC_ERROR: "vce", // there was an error fetching data about the voice channel
  }

  /**
   * @description Initiate a new Revoice instance
   *
   * @param  {(Login|string)} loginData The way to login. If you're using a bot use your token, otherwise specify an email and password.
   * @param {(APIConfig)} [apiConfig={}] A configuration object for revolt-api. @see {@link https://github.com/insertish/oapi#example} The last example for further information
   * @return {Revoice}
   */
  constructor(loginData, apiConfig={}) {
    super();
    this.session = null;
    this.login(loginData, apiConfig);

    this.signals = new Map();
    this.signaling = new Signaling(this.api);

    this.transports = new Map();
    this.devices = new Map(); // list of devices by server id
    this.connected = []; // list of channels the bot is connected to
    this.connections = new Map();

    this.users = new Map();

    this.state = Revoice.State.OFFLINE;

    return this;
  }
  async login(data, config) {
    if (!data.email) return this.api = new API({ ...config, authentication: { revolt: data } });

    this.api = new API();
    const d = await this.api.post("/auth/session/login", data);
    if (d.result != "Success") throw "MFA not implemented or login not successfull!";
    this.session = d;
    this.connect(config);
  }
  async connect(config) {
    this.api = new API({
      ...config,
      authentication: {
        revolt: this.session
      }
    });
  }
  updateState(state) {
    this.state = state;
    this.emit("state", state);
  }
  static uid() {
    return Date.now().toString(36) + Math.random().toString(36).substr(2);
  }


  /**
   * @typedef UserData
   * @property {User} user The Revoice user object associated with the user
   * @property {VoiceConnection} connection The voice connection that is connected to the user
   */
  /**
   * @description Retrieve the user object
   *
   * @param  {string} id The id of the user
   * @return {UserData} An object containing the Revoice user object and the voice connection, the user is in.
   */
  getUser(id) {
    if (!this.users.has(id)) return false; // no data about the user in cache
    const user = this.users.get(id);
    if (!user) return false;
    if (!user.connected) return { user };
    const connection = this.connections.get(user.connectedTo);
    return { user, connection };
  }
  knowsUser(id) { // might not be up-to-date because of leaving
    return this.users.has(id);
  }

  /**
   * @description Join a specified channel
   * @example
   * voice.join("channel", 60).then(connection => { // leave after 60 seconds of inactivity
   *   const player = new MediaPlayer();
   *   connection.play(player);
   *   player.playFile("audio.mp3");
   * });
   *
   * @param  {string} channelId        The id of the voice channel you want the bot to join
   * @param  {(false|number)} leaveIfEmpty=false Specifies the amount of time in sconds, after which the bot leaves an empty voice channel. If this is set to `false`, the bot will stay unless told to leave
   * @return {Promise<VoiceConnection>} A promise containing the resulting VoiceConnection for this channel.
   */
  join(channelId, leaveIfEmpty=false) { // leaveIfEmpty == amount of seconds the bot will wait before leaving if the room is empty
    return new Promise((res, rej) => {
      this.api.get("/channels/" + channelId).then(data => {
        if (data.channel_type != "VoiceChannel" && data.channel_type != "Group") return rej(Revoice.Error.NOT_A_VC);
        if (this.devices.has(channelId)) {
          return rej(Revoice.Error.ALREADY_CONNECTED);
        }

        const signaling = new Signaling(this.api);
        const device = Revoice.createDevice();

        const connection = new VoiceConnection(channelId, this, {
          signaling: signaling,
          device: device,
          leaveOnEmpty: leaveIfEmpty
        });
        connection.on("autoleave", () => {
          this.connections.delete(channelId);
        });
        connection.on("userLeave", (u) => {
          if (!this.users.has(u.id)) return; // is leaving anyway
          const user = this.users.get(u.id);
          user.connected = false;
          user.connectedTo = null;
          this.users.set(u.id, user);
        });
        connection.updateState(Revoice.State.JOINING);
        this.connections.set(channelId, connection);
        res(connection);
      }).catch((e) => {
        console.log(e);
        rej(Revoice.Error.VC_ERROR);
      });
    });
  }

  /**
   * @description Retrieve the VoiceConnection object for a specified voice channel
   *
   * @param  {string} channelId The id of the voice channel
   * @return {VoiceConnection}           The voice connection object
   */
  getVoiceConnection(channelId) {
    return this.connections.get(channelId);
  }
}

module.exports = Revoice;