Comrade: Stack Bot for Zoom

Bot that manages stack for meetings. Active if your name is "Comrade" when you join.

当前为 2021-01-20 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Comrade: Stack Bot for Zoom
  3. // @description Bot that manages stack for meetings. Active if your name is "Comrade" when you join.
  4. // @version 2.0
  5. // @grant none
  6. // @include https://zoom.us/j/*
  7. // @include https://*.zoom.us/j/*
  8. // @include https://zoom.us/s/*
  9. // @include https://*.zoom.us/s/*
  10. // @include https://*.zoom.us/wc/*
  11. // @namespace https://greasyfork.org/users/22981
  12. // ==/UserScript==
  13.  
  14.  
  15. /*
  16. ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4)
  17.  
  18. Copyright © 2021 Adam Novak
  19.  
  20. This is anti-capitalist software, released for free use by individuals and
  21. organizations that do not operate by capitalist principles.
  22.  
  23. Permission is hereby granted, free of charge, to any person or organization
  24. (the "User") obtaining a copy of this software and associated documentation
  25. files (the "Software"), to use, copy, modify, merge, distribute, and/or sell
  26. copies of the Software, subject to the following conditions:
  27.  
  28. 1. The above copyright notice and this permission notice shall be included in
  29. all copies or modified versions of the Software.
  30.  
  31. 2. The User is one of the following:
  32. a. An individual person, laboring for themselves
  33. b. A non-profit organization
  34. c. An educational institution
  35. d. An organization that seeks shared profit for all of its members, and
  36. allows non-members to set the cost of their labor
  37.  
  38. 3. If the User is an organization with owners, then all owners are workers and
  39. all workers are owners with equal equity and/or equal vote.
  40.  
  41. 4. If the User is an organization, then the User is not law enforcement or
  42. military, or working for or under either.
  43.  
  44. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY
  45. KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
  46. FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE
  47. LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
  48. CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
  49. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  50. */
  51.  
  52. //// CONFIG
  53.  
  54. const BOT_NAME = 'Comrade'
  55. const QUEUE_KEYWORD = 'stack'
  56. const DEQUEUE_KEYWORD = 'pop'
  57. const GIVEUP_KEYWORD = 'unstack'
  58. const REMIND_KEYWORD = 'who'
  59. const HELP_KEYWORD = 'help'
  60.  
  61. const HELP_TEXT = `
  62. ${BOT_NAME} is a bot who can stack. Type:
  63. 1. "${QUEUE_KEYWORD}" to put yourself on stack.
  64. 2. "${DEQUEUE_KEYWORD}" when you are done so the bot can announce who is next.
  65. 3. "${GIVEUP_KEYWORD}" if you are on stack but don't want to be.
  66. 4. "${REMIND_KEYWORD}" if you forgot who is on stack.
  67.  
  68. Type "${HELP_KEYWORD}" to see this message again.
  69. `
  70.  
  71. // How many pixels can we be scrolled from the bottom and still think all
  72. // messages we see are the latest ones?
  73. const END_OF_HISTORY_HEIGHT = 10
  74.  
  75. //// LIBRARY
  76.  
  77. // Let async code wait.
  78. // See: <https://stackoverflow.com/a/39914235>
  79. function sleep(ms) {
  80. return new Promise(resolve => setTimeout(resolve, ms));
  81. }
  82.  
  83. // Find a button by text. Returns the button element, or undefined.
  84. function findButton(text) {
  85. let all_buttons = document.getElementsByTagName('button')
  86. let this_button = undefined
  87. for (let button of all_buttons) {
  88. if (!this_button && button.innerText.includes(text)) {
  89. this_button = button
  90. }
  91. }
  92. return this_button
  93. }
  94.  
  95. //// ENSURE WEB CLIENT ACCESSIBLE
  96.  
  97. function showWebClientLink() {
  98. let results = document.getElementsByClassName('webclient')
  99. if (results[0]) {
  100. results[0].classList.remove('hideme')
  101. }
  102. }
  103.  
  104. // We may be on a non-meeting page. Make sure people can join.
  105. showWebClientLink()
  106.  
  107.  
  108. //// BOT
  109.  
  110. // Wait for the client to start, open necessary panes, and set name
  111. async function botStartup() {
  112. try {
  113. while (true) {
  114. // Wait until ready. We assume we are ready when the join audio button comes up
  115. console.log('Waiting for join audio button...')
  116. let audio_button = document.getElementsByClassName('join-audio-by-voip__join-btn')[0]
  117. if (audio_button && audio_button.offsetParent != null) {
  118. // It exists and is visible
  119. break
  120. }
  121. await sleep(1000)
  122. }
  123. console.log('Audio button visible')
  124. while (true) {
  125. // Wait for the chat and participants buttons
  126. // They may be in the bore button.
  127. let more_button = document.getElementsByClassName('more-button')[0]
  128. let participants_button = findButton('Participants')
  129. // TODO: look in more menu
  130. if (participants_button) {
  131. // The button exists, so click it and move on
  132. participants_button.click()
  133. break
  134. }
  135. // Otherwise try again
  136. console.log('Waiting for participants button...')
  137. await sleep(1000)
  138. }
  139. // We only want to operate if we joined with the correct name.
  140. // That way you can leave the script enabled and join the meeting as you
  141. // and as the bot.
  142. let named_right = false;
  143. while (true) {
  144. // When the user list comes up, find and hover over ourselves
  145. console.log('Waiting for own participant entry...')
  146. // We are always at the top
  147. let user_entry = document.getElementById('participants-list-0')
  148. if (user_entry) {
  149. // The entry exists
  150. let user_name = user_entry.innerText.split('\n')[0].trim()
  151. console.log('I am: ', user_name)
  152. if (user_name.includes(BOT_NAME) || BOT_NAME.includes(user_name)) {
  153. // We should be the bot
  154. named_right = true;
  155. }
  156. break
  157. }
  158. // Otherwise try again
  159. await sleep(1000)
  160. }
  161. if (named_right) {
  162. // Be the bot!
  163. await enforceChat()
  164. console.log(BOT_NAME + ' is ready.')
  165. // Introduce ourselves
  166. await sleep(3000)
  167. await say(BOT_NAME + ' is ready.')
  168. await say(showHelp())
  169. // Move on to the main loop.
  170. mainLoop()
  171. } else {
  172. console.log('Name is not ' + BOT_NAME + ': not running')
  173. }
  174. } catch (e) {
  175. console.error('Comrade initialization error: ', e)
  176. }
  177. }
  178.  
  179. // Make the chat and participants panes come up, and attach the chat mutation listener.
  180. // No-op if they are visible already.
  181. // Need to run periodically in case screen sharing minimizes them.
  182. async function enforceChat() {
  183.  
  184. let opened_pane = false
  185.  
  186. while (true) {
  187. // Wait for the chat and participants buttons
  188. // They may be in the bore button.
  189. let more_button = document.getElementsByClassName('more-button')[0]
  190. let chat_button = findButton('Chat')
  191.  
  192. // TODO: look in more menu
  193. if (chat_button) {
  194. // The button exists
  195. if (chat_button.getAttribute('aria-label') == 'open the chat pane') {
  196. // Pane isn't open yet
  197. chat_button.click()
  198. opened_pane = true
  199. }
  200. break
  201. }
  202. // Otherwise try again
  203. console.log('Waiting for chat button...')
  204. await sleep(1000)
  205. }
  206. if (opened_pane) {
  207. // Now grab the chat log
  208. let chat_log = document.getElementsByClassName('chat-virtualized-list')[0]
  209. console.log('Chat log: ', chat_log)
  210. // Watch for chats
  211. let chat_watcher = new MutationObserver(chatChange)
  212. chat_watcher.observe(chat_log, {childList: true, subtree: true, characterDataOldValue: true})
  213. console.log('Watching with: ', chat_watcher)
  214. }
  215. }
  216.  
  217. // Handle changes to the chat log and translate them into internal chat message calls
  218. function chatChange(mutations, chat_watcher) {
  219. try {
  220. // Find the chat scroll and see where it is scrolled to
  221. let chat_scroller = document.getElementsByClassName('chat-virtualized-list')[0]
  222. for (let record of mutations) {
  223. if ((record.addedNodes.length == 0 && record.type != 'characterData') || (record.addedNodes.length == 1 && record.addedNodes[0].classList.contains('chat-item__chat-info-time-stamp'))) {
  224. // These are noise
  225. continue
  226. }
  227. console.log(record)
  228. if (record.nextSibling) {
  229. // Zoom pages the chat messages in and out as we scroll the chat, and also when it feels like.
  230. // We can skip most of them here, but they will be re-seen if we are scrolling up and down.
  231. continue
  232. }
  233. // We may get new nodes, or changed text.
  234. if (record.type == 'characterData') {
  235. // New message from the same person as last time. Assume it is an append.
  236. let chat_entry = record.target.parentElement.parentElement
  237. let chat_sender_item = chat_entry.getElementsByClassName('chat-item__sender')[0]
  238. if (chat_sender_item) {
  239. // Who said it?
  240. let chat_sender = chat_sender_item.innerText
  241. // Is the chat private to me?
  242. let chat_private = (chat_entry.getElementsByClassName('chat-privately')[0] !== undefined)
  243. // Trim off the old text and the intervening newline
  244. let chat_content = record.target.textContent.substr(record.oldValue.length + 1)
  245. console.log(chat_sender + (chat_private ? ' privately' : '') + ' also says: ' + chat_content)
  246. onChat(chat_sender, chat_content, chat_private)
  247. }
  248. }
  249. if (chat_scroller && chat_scroller.scrollTopMax - chat_scroller.scrollTop > END_OF_HISTORY_HEIGHT) {
  250. // We are probably scrolling around the list and not getting new messages.
  251. // TODO: if you scroll up and new messages come in they will be skipped!
  252. console.log('Skipping new nodes as we are not at the end of history: ' + chat_scroller.scrollTop + '/' + chat_scroller.scrollTopMax)
  253. continue
  254. }
  255. for (let chat_entry of record.addedNodes) {
  256. let chat_sender_item = chat_entry.getElementsByClassName('chat-item__sender')[0]
  257. let chat_content_item = chat_entry.getElementsByTagName('pre')[0]
  258. if (chat_sender_item && chat_content_item) {
  259. // Who said it?
  260. let chat_sender = chat_sender_item.innerText
  261. // Is the chat private to me?
  262. let chat_private = (chat_entry.getElementsByClassName('chat-privately')[0] !== undefined)
  263. let chat_content = chat_content_item.innerText
  264. if (chat_sender !== undefined && chat_content !== undefined) {
  265. console.log(chat_sender + (chat_private ? ' privately' : '') + ' says: ' + chat_content)
  266. onChat(chat_sender, chat_content, chat_private)
  267. }
  268. }
  269. }
  270. }
  271. } catch (e) {
  272. console.error('Comrade element watch error: ', e)
  273. }
  274.  
  275. }
  276.  
  277.  
  278. // Keep track of the meeting stack
  279. let stack = []
  280.  
  281. // All the command functions return a result string.
  282.  
  283. // Put a person on the stack, if not on stack already
  284. // Special handling of a successful add: tell everyone, even if add was
  285. // private.
  286. function addToStack(who) {
  287. let result = ''
  288. if (stack.includes(who)) {
  289. return (who + ' is already on stack.')
  290. } else {
  291. stack.push(who)
  292. say(reportStack())
  293. return undefined
  294. }
  295. }
  296.  
  297. // Remove the oldest person from the stack
  298. function popFromStack() {
  299. let result = ''
  300. removed = stack[0]
  301. stack = stack.slice(1)
  302. if (removed) {
  303. result += ('Removed ' + removed + ' from stack.')
  304. }
  305. result += '\n' + reportStack()
  306. return result
  307. }
  308.  
  309. // Drop the given person from stack
  310. function removeFromStack(who) {
  311. let result = ''
  312. let new_stack = []
  313. let removed = false
  314. for (let person of stack) {
  315. if (person != who) {
  316. new_stack.push(person)
  317. } else {
  318. removed = true
  319. }
  320. }
  321. stack = new_stack
  322. if (removed) {
  323. result += ('Removed ' + who + ' from stack')
  324. } else {
  325. result += (who + 'was not on stack')
  326. }
  327. result += '\n' + reportStack()
  328. return result
  329. }
  330.  
  331. // Read out the stack
  332. function reportStack() {
  333. let result = ''
  334. if (stack.length == 0) {
  335. result += 'Stack is empty'
  336. } else {
  337. result += ('\nNext on stack is: ' + stack[0])
  338. if (stack.length > 1) {
  339. result += '\nAfter that:'
  340. for (let i = 1; i < stack.length; i++) {
  341. result += ('\n' + i + '. ' + stack[i])
  342. }
  343. }
  344. }
  345. return result
  346. }
  347.  
  348. // Print the help text
  349. function showHelp() {
  350. let result = HELP_TEXT
  351. return result
  352. }
  353.  
  354. // We use this queue to make sure we completely process one incoming message before the next one starts being handled.
  355. // It holds arrays of name, message, private flag
  356. let incoming_messages = []
  357.  
  358. // Called when a new chat message comes in.
  359. // Just adds it to the queue fro processing.
  360. function onChat(sender, message, private) {
  361. incoming_messages.push([sender, message, private])
  362. }
  363.  
  364. // Main loop that handles chat messages off the incoming queue
  365. async function mainLoop() {
  366. try {
  367. await enforceChat()
  368. if (incoming_messages.length > 0) {
  369. // We have mail!
  370. // Pop a message
  371. let [sender, message, private] = incoming_messages[0]
  372. incoming_messages = incoming_messages.slice(1)
  373. // And handle it, waiting
  374. await processChat(sender, message, private)
  375. console.log('Ready for next message.')
  376. }
  377. } catch (e) {
  378. console.error('Comrade main loop exception: ', e)
  379. }
  380.  
  381. // Run again
  382. setTimeout(mainLoop, 100)
  383. }
  384.  
  385. // Function that actually processes a chat message.
  386. // MUST NOT have two copies running at once. MUST be awaited.
  387. async function processChat(sender, message, private) {
  388. try {
  389. // For getting on stack, we want to accept things like "ash stack" or "ash" from user "Ash Ketchum (he/him)".
  390. // So we need to break everything into words.
  391. let command_words = message.toLowerCase().split(' ').filter((x) => x != '')
  392. let user_words = sender.toLowerCase().split(' ').filter((x) => x != '')
  393. let reply = undefined
  394. if (command_words.length == 1) {
  395. // Just one command
  396. let command = command_words[0]
  397. if (command == 'ping') {
  398. reply = 'pong'
  399. } else if (command == QUEUE_KEYWORD || user_words.includes(command)) {
  400. reply = addToStack(sender)
  401. } else if (command == DEQUEUE_KEYWORD) {
  402. reply = popFromStack()
  403. } else if (command == GIVEUP_KEYWORD) {
  404. reply = removeFromStack(sender)
  405. } else if (command == REMIND_KEYWORD) {
  406. reply = reportStack()
  407. } else if (command == HELP_KEYWORD) {
  408. reply = showHelp()
  409. } else if (private) {
  410. reply = `Unrecognized command. Say "${HELP_KEYWORD}" for help.`
  411. }
  412. } else if (command_words.length > 1) {
  413. // If all the words in the command are either the stack command or parts of the
  414. // user's name, put them on stack.
  415. let all_stacky = true
  416. for (let word of command_words) {
  417. if (word != QUEUE_KEYWORD && !user_words.includes(word)) {
  418. all_stacky = false
  419. break
  420. }
  421. }
  422. if (all_stacky) {
  423. reply = addToStack(sender)
  424. }
  425. }
  426. if (reply !== undefined) {
  427. // This merits a response
  428. if (private) {
  429. // Reply directly
  430. await whisper(sender, reply)
  431. } else {
  432. // Reply to everyone
  433. await say(reply)
  434. }
  435. }
  436. } catch (e) {
  437. console.error('Comrade message interpretation error: ', e)
  438. }
  439. }
  440.  
  441. // Type in the chat.
  442. async function say(message) {
  443. // Just whisper by saying to everyone...
  444. await whisper("Everyone", message)
  445. }
  446.  
  447. // Type to someone in chat
  448. async function whisper(who, message) {
  449. try {
  450. console.log('Sending to ' + who + ': ' + message)
  451. // Open the menu of people
  452. let chat_picker = document.getElementById('chatReceiverMenu')
  453. chat_picker.click()
  454. await sleep(100)
  455. // Find the dropdown
  456. let chat_dropdown = document.getElementsByClassName('chat-receiver-list__scrollbar')[0]
  457. // It is full of links. Find the link to click.
  458. let found = false
  459. for (let link of chat_dropdown.getElementsByTagName('a')) {
  460. if (link.innerText == who || (who == "Everyone" && link.innerText == "Everyone (in Meeting)")) {
  461. // What "Everyone" looks like depends on if you are the host or not.
  462. link.click()
  463. found = true
  464. }
  465. }
  466. if (!found) {
  467. console.log('Cound not find ' + who + ' to talk to')
  468. return
  469. } else {
  470. // Wait to take effect
  471. await sleep(100)
  472. }
  473. let chat_box = document.getElementsByClassName('chat-box__chat-textarea')[0]
  474. console.log('Chat box: ', chat_box)
  475. chat_box.value = message
  476. let change_event = new Event('change', {
  477. 'view': window,
  478. 'bubbles': true,
  479. 'cancelable': true
  480. })
  481. chat_box.dispatchEvent(change_event)
  482. // All the keyboard event properties are read only so we have to set them up front.
  483. let enter_event = new KeyboardEvent('keydown', {
  484. bubbles: true,
  485. cancelable: true,
  486. code: "Enter",
  487. key: "Enter",
  488. keyCode: 13,
  489. which: 13
  490. })
  491. chat_box.dispatchEvent(enter_event)
  492. } catch (e) {
  493. console.error('Comrade whisper transmission error: ', e)
  494. }
  495. }
  496.  
  497.  
  498. botStartup()
  499.  
  500.  
  501.  
  502.  
  503.  
  504.  
  505.