Comrade: Stack Bot for Zoom

Bot that manages stack for meetings. "Stack" puts you on stack and "Pop" drops the oldest person.

Tính đến 10-01-2021. Xem phiên bản mới nhất.

// ==UserScript==
// @name     Comrade: Stack Bot for Zoom
// @description Bot that manages stack for meetings. "Stack" puts you on stack and "Pop" drops the oldest person.
// @version  1.0
// @grant    none
// @include https://zoom.us/j/*
// @include https://*.zoom.us/j/*
// @include https://zoom.us/s/*
// @include https://*.zoom.us/s/*
// @include https://*.zoom.us/wc/*
// @namespace https://greasyfork.org/users/22981
// ==/UserScript==


/*
ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4)

Copyright © 2021 Adam Novak

This is anti-capitalist software, released for free use by individuals and
organizations that do not operate by capitalist principles.

Permission is hereby granted, free of charge, to any person or organization
(the "User") obtaining a copy of this software and associated documentation
files (the "Software"), to use, copy, modify, merge, distribute, and/or sell
copies of the Software, subject to the following conditions:

1. The above copyright notice and this permission notice shall be included in
   all copies or modified versions of the Software.

2. The User is one of the following:
  a. An individual person, laboring for themselves
  b. A non-profit organization
  c. An educational institution
  d. An organization that seeks shared profit for all of its members, and
     allows non-members to set the cost of their labor

3. If the User is an organization with owners, then all owners are workers and
   all workers are owners with equal equity and/or equal vote.

4. If the User is an organization, then the User is not law enforcement or
   military, or working for or under either.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY
KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

//// CONFIG

const BOT_NAME = 'Comrade'
const QUEUE_KEYWORD = 'stack'
const DEQUEUE_KEYWORD = 'pop'
const GIVEUP_KEYWORD = 'unstack'
const REMIND_KEYWORD = 'who'
const HELP_KEYWORD = 'help'

const HELP_TEXT = `
${BOT_NAME} is a bot who can stack. Type:
1. "${QUEUE_KEYWORD}" to put yourself on stack.
2. "${DEQUEUE_KEYWORD}" when you are done so the bot can announce who is next.
3. "${GIVEUP_KEYWORD}" if you are on stack but don't want to be.
4. "${REMIND_KEYWORD}" if you forgot who is on stack.

Type "${HELP_KEYWORD}" to see this message again.
`

//// LIBRARY

// Let async code wait.
// See: <https://stackoverflow.com/a/39914235>
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// Find a button by text. Returns the button element, or undefined.
function findButton(text) {
  let all_buttons = document.getElementsByTagName('button')
  var this_button = undefined
  for (let button of all_buttons) {
    if (!this_button && button.innerText.includes(text)) {
      this_button = button
    }
  }
  return this_button
}

//// ENSURE WEB CLIENT ACCESSIBLE

function showWebClientLink() {
  let results = document.getElementsByClassName('webclient')
 
  if (results[0]) {
    results[0].classList.remove('hideme')
  }
}

// We may be on a non-meeting page. Make sure people can join.
showWebClientLink()


//// BOT

// Wait for the client to start, open necessary panes, and set name
async function botStartup() {
  try {
    while (true) {
      // Wait until ready. We assume we are ready when the join audio button comes up
      console.log('Waiting for join audio button...')
      let audio_button = document.getElementsByClassName('join-audio-by-voip__join-btn')[0]
      if (audio_button && audio_button.offsetParent != null) {
        // It exists and is visible
        break
      }
      await sleep(1000)
    }
    console.log('Audio button visible')
    
    while (true) {
      // Wait for the chat and participants buttons
      // They may be in the bore button.
      console.log('Waiting for chat and participants buttons...')
      let more_button = document.getElementsByClassName('more-button')[0]
      let participants_button = findButton('Participants')
      let chat_button = findButton('Chat')

      // TODO: look in more menu
      
      if (participants_button && chat_button) {
        // The buttons exist, so click them and move on
        participants_button.click()
        chat_button.click()
        break
      }
      
      // Otherwise try again
      await sleep(1000)
    }
    
    // We only need to rename sometimes
    var named_right = false;
    
    while (true) {
      // When the user list comes up, find and hover over ourselves
      console.log('Waiting for own participant entry...')
      // We are always at the top
      let user_entry = document.getElementById('participants-list-0')
      
      if (user_entry) {
        // The entry exists
        if (user_entry.innerText.startsWith(BOT_NAME)) {
          // No need to change name
          named_right = true;
        } else {
          // Hover user entry to create rename button
          // We can't hit the react hover button because it's hiding in a Symbol-named property or something.
          // This fake event doesn't always work...
          let event = new MouseEvent('mouseover', {
            'view': window,
            'bubbles': true,
            'cancelable': true
          })
          user_entry.dispatchEvent(event)
        }
        break
      }
      
      // Otherwise try again
      await sleep(1000)
    }
    
    while (!named_right) {
      // When the user buttons come up, find and click the rename button
      console.log('Waiting for rename button...')
      let rename_button = findButton('Rename')
      
      if (rename_button) {
        // The button exists, so click it and move on
        rename_button.click()
        break
      }
      
      // Otherwise try again
      await sleep(1000)
    }
    
    while (!named_right) {
      // When the rename dialog comes up, enter our name and click save.
      console.log('Waiting for rename controls...')
      let new_name_field = document.getElementById('newname')
      let save_button = findButton('Save')
      
      if (new_name_field && save_button) {
        // The controls exist, so operate them and move on
        console.log('Setting name')
        new_name_field.value = BOT_NAME
        console.log('Sending change')
        let event = new Event('change', {
          'view': window,
          'bubbles': true,
          'cancelable': true
        })
        new_name_field.dispatchEvent(event)
        save_button.click()
        named_right = true
        break
      }
      
      // Otherwise try again
      await sleep(1000)
    }
    
    // Now grab the chat log
    let chat_log = document.getElementsByClassName('chat-virtualized-list')[0]
    console.log('Chat log: ', chat_log)
    
    // Watch for chats
    let chat_watcher = new MutationObserver(chatChange)
    chat_watcher.observe(chat_log, {childList: true, subtree: true, characterDataOldValue: true})
    console.log('Watching with: ', chat_watcher)
    
    console.log(BOT_NAME + ' is ready.')
    
    await sleep(3000)
    say(BOT_NAME + ' is ready.')
    showHelp()
    
  } catch (e) {
    console.error('Comrade initialization error: ', e)
  }
}

// Handle changes to the chat log and translate them into internal chat message calls
function chatChange(mutations, chat_watcher) {
  try {
    for (let record of mutations) {
      console.log(record)
      // We may get new nodes, or changed text.
      if (record.type == 'characterData') {
        // New message from the same person as last time. Assume it is an append.
        let chat_entry = record.target.parentElement.parentElement
        let chat_sender_item = chat_entry.getElementsByClassName('chat-item__sender')[0]
        if (chat_sender_item) {
          let chat_sender = chat_sender_item.innerText
          // Trim off the old text and the intervening newline
          let chat_content = record.target.textContent.substr(record.oldValue.length + 1)
          console.log(chat_sender + ' also says: ' + chat_content)
          onChat(chat_sender, chat_content)
        }
      }
      
      for (let new_node of record.addedNodes) {
        let chat_sender_item = new_node.getElementsByClassName('chat-item__sender')[0]
        let chat_contant_item = new_node.getElementsByTagName('pre')[0]
        if (chat_sender_item && chat_content_item) {
          let chat_sender = chat_sender_item.innerText
          let chat_content = chat_contant_item.innerText
          if (chat_sender !== undefined && chat_content !== undefined) {
            console.log(chat_sender + ' says: ' + chat_content)
            onChat(chat_sender, chat_content)
          }
        }
      }
      
    }
  } catch (e) {
    console.error('Comrade element watch error: ', e)
  }

}

// Keep track of the meeting stack
var stack = []

// Put a person on the stack, if not on stack already
function addToStack(who) {
  if (stack.includes(who)) {
    say(who + ' is already on stack.')
  } else {
    stack.push(who)
    reportStack()
  }
}

// Remove the oldest person from the stack
function popFromStack() {
  removed = stack[0]
  stack = stack.slice(1)
  
  if (removed) {
    say('Removed ' + removed + ' from stack.')
  }
  reportStack()
}

// Drop the given person from stack
function removeFromStack(who) {
  let new_stack = []
  var removed = false
  for (let person of stack) {
    if (person != who) {
      new_stack.push(person)
    } else {
      removed = true
    }
  }
  stack = new_stack
  
  if (removed) {
    say('Removed ' + who + ' from stack')
  } else {
    say(who + 'was not on stack')
  }
  
  reportStack()
}

// Read out the stack
function reportStack() {
  if (stack.length == 0) {
    say('Stack is empty')
  } else {
    say('Next on stack is: ' + stack[0])
    if (stack.length > 1) {
      say('After that:')
      for (let i = 1; i < stack.length; i++) {
        say(i + '. ' + stack[i])
      }
    }
  }
}

// Print the help text
function showHelp() {
  say(HELP_TEXT)
}
    
function onChat(sender, message) {
  try {
    command = message.toLowerCase()
    if (command == 'ping') {
      say('pong')
    } else if (command == QUEUE_KEYWORD) {
      addToStack(sender)
    } else if (command == DEQUEUE_KEYWORD) {
      popFromStack()
    } else if (command == GIVEUP_KEYWORD) {
      removeFromStack(sender)
    } else if (command == REMIND_KEYWORD) {
      reportStack()
    } else if (command == HELP_KEYWORD) {
      showHelp()
    }
  } catch (e) {
    console.error('Comrade message interpretation error: ', e)
  }
}

// Type in the chat.
async function say(message) {
  try {
    console.log('Sending: ' + message)
    
    // Need to wait for React to settle from the user doing things
    await sleep(100)
  
    let chat_box = document.getElementsByClassName('chat-box__chat-textarea')[0]
    console.log('Chat box: ', chat_box)
    
    chat_box.value = message
  
    let change_event = new Event('change', {
        'view': window,
        'bubbles': true,
        'cancelable': true
      })
    chat_box.dispatchEvent(change_event)
    
    // All the keyboard event properties are read only so we have to set them up front.
    let enter_event = new KeyboardEvent('keydown', {
      bubbles: true,
      cancelable: true,
      code: "Enter",
      key: "Enter",
      keyCode: 13,
      which: 13
    })
    chat_box.dispatchEvent(enter_event)
  } catch (e) {
    console.error('Comrade message transmission error: ', e)
  }
}


botStartup()