Created
April 25, 2023 01:03
-
-
Save THOUSAND-SKY/91551e4f4d9cabecec6b962f013cccca to your computer and use it in GitHub Desktop.
Slack: Always Active user script
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Slack: Always Stay Active -- slight update from version available in below url | |
// @namespace https://ericdraken.com/slack-always-stay-active | |
// @version 1.0.2 | |
// @description Always stay active on Slack. | |
// @author Eric Draken (ericdraken.com) | |
// @match https://app.slack.com/client/* | |
// @grant unsafeWindow | |
// @run-at document-start | |
// ==/UserScript== | |
/* eslint-disable no-proto */ | |
/* eslint-disable accessor-pairs */ | |
/* eslint-disable no-global-assign */ | |
// `window.eval` is required in FireMonkey (Firefox only), dunno about other user script managers. | |
// If you remove it, then unescape the backticks. | |
// Diff vs 1.0.1 is the new setInterval on line 71. | |
window.eval(` | |
((window, mutateEvent) => { | |
'use strict'; | |
const INACTIVE_THRESHOLD_SECS = 60 * 5; | |
const PING_THRESHOLD_SECS = 60; | |
const PAGE_ERROR_THRESHOLD = 10; | |
const GREEN = '#2bac76'; | |
const YELLOW = '#ac7f2b'; | |
const RED = '#ac3e2b'; | |
const GREY = '#d0d0d0'; | |
/** Do not change below this line **/ | |
const APPNAME = 'SLACK_ASA'; | |
const PING_MSG = {"type": "ping", "id": null}; | |
const PONG_MSG = {"type": "pong", "reply_to": null}; | |
const TYPING_MSG = {"type": "typing", "channel": null, "id": null}; | |
const TICKLE_MSG = {"type": "tickle", "id": null}; | |
const PRESENCE_ACTIVE_MSG = {"type": "presence_change", "presence": "active", "users": [null]}; | |
const PRESENCE_AWAY_MSG = {"type": "presence_change", "presence": "away", "users": [null]}; | |
const MANUAL_PRESENCE_ACTIVE_MSG = {"type": "manual_presence_change", "presence": "active"}; | |
const MANUAL_PRESENCE_AWAY_MSG = {"type": "manual_presence_change", "presence": "away"}; | |
const TEXT_MSG = {"type": "message", "user": null, "text": null}; | |
const PULSE_CLASS = 'asa-pulse'; | |
let errorCount = 0; | |
let userId = '--not set--'; | |
// Prefix our messages with the app name and WS id for console filtering | |
const protoConsole = { | |
_socketId: '', | |
_console: window.console || false, | |
_doLog(level, color, ...args) { | |
const ind = this._socketId !== '' ? \`[\${this._socketId}]\` : ''; | |
this._console && this._console[level](\`%c\${APPNAME}\${ind}:\`, \`color: \${color}\`, ...args); | |
}, | |
log(...args) { | |
this._doLog('log', GREEN, ...args); | |
}, | |
info(...args) { | |
this._doLog('info', GREEN, ...args); | |
}, | |
error(...args) { | |
this._doLog('error', RED, ...args); | |
}, | |
debug(...args) { | |
this._doLog('debug', YELLOW, ...args); | |
} | |
}; | |
// Root logger | |
const console = Object.assign({}, protoConsole, {_socketId: ''}); | |
// This was added in version 1.0.2. | |
const interv = window.setInterval(() => { | |
const el = window.document.querySelector(".p-ia__nav__user__avatar > span:nth-child(1) > img:nth-child(1)"); | |
console.log('el', el) | |
const url = el.src; | |
console.log("matching from:", url); | |
userId = url.match(/(?:[^-]*-){2}([^-\\n]+)/)[1] | |
console.log('loaded userId as', userId) | |
window.clearInterval(interv) | |
}, 5000); | |
const statusDiv = { | |
id: 'SAA229064402df15c8079ac', // Something random | |
_div: null, | |
getStatusDiv() { | |
if (this._div && this._div.style) { | |
return this._div; | |
} | |
this._div = document.createElement('div'); | |
this._div.id = this.id; | |
this._div.style.cssText = '' + | |
'display:block;position:fixed;overflow:hidden;' + | |
'top:0;left:0;width:100%;height:2px;opacity:0.5;' + | |
'z-index:2147483647;background:#fff;' | |
document.body.appendChild(this._div); | |
// Animation - pulse effect on the banner | |
const style = document.createElement('style'); | |
style.innerHTML = \` | |
.\${PULSE_CLASS} { | |
animation-direction: normal; | |
animation: asapulse linear 1s 1; | |
} | |
@keyframes asapulse { | |
0% { | |
-webkit-transform:scale(1); | |
-moz-transform:scale(1); | |
-ms-transform:scale(1); | |
-o-transform:scale(1); | |
transform:scale(1); | |
} | |
70% { | |
-webkit-transform:scale(4); | |
-moz-transform:scale(4); | |
-ms-transform:scale(4); | |
-o-transform:scale(4); | |
transform:scale(4); | |
} | |
100% { | |
-webkit-transform:scale(1); | |
-moz-transform:scale(1); | |
-ms-transform:scale(1); | |
-o-transform:scale(1); | |
transform:scale(1); | |
} | |
}\`; | |
document.head.appendChild(style); | |
return this._div; | |
}, | |
setActive() { | |
const div = this.getStatusDiv(); | |
if (div.dataset.bg !== GREEN) { | |
console.log('Set active'); | |
} | |
div.dataset.bg = div.style.background = GREEN; | |
}, | |
setAway() { | |
const div = this.getStatusDiv(); | |
if (div.dataset.bg !== GREY) { | |
console.log('Set away'); | |
} | |
div.dataset.bg = div.style.background = GREY; | |
}, | |
setBooting() { | |
const div = this.getStatusDiv(); | |
div.style.background = YELLOW; | |
this.doPulse(); | |
console.debug('Set booting'); | |
}, | |
setProblem(e) { | |
const div = this.getStatusDiv(); | |
div.style.background = RED; | |
this.doPulse(); | |
console.error(\`Problem: \${e}\`); | |
}, | |
doPulse() { | |
const div = this.getStatusDiv(); | |
div.classList.remove(PULSE_CLASS); | |
div.getClientRects(); // Trigger a reflow | |
div.classList.add(PULSE_CLASS); | |
console.debug('Do pulse'); | |
} | |
}; | |
// Of all the multiple XHR objects, only intercept the boot request's user id | |
const xhrProtoOpen = window.XMLHttpRequest.prototype.open; | |
window.XMLHttpRequest.prototype.open = function () { | |
const that = this; | |
if (window.XMLHttpRequest.prototype.open !== xhrProtoOpen) { | |
window.XMLHttpRequest.prototype.open = xhrProtoOpen; | |
that.addEventListener('load', (ev) => { | |
const xConsole = Object.assign({}, protoConsole, {_socketId: 'XHR'}); | |
const bootData = JSON.parse(ev.currentTarget.responseText); | |
// Get the logged-in user id | |
try { | |
userId = bootData.self.id; | |
xConsole.log('My Slack user id:', userId); | |
} catch (e) { | |
xConsole.error('Unable to get the logged in user id. Commands disabled.'); | |
xConsole.log(JSON.stringify(bootData, null, 2)); | |
} | |
}); | |
} | |
xhrProtoOpen.apply(that, arguments); | |
}; | |
const objMatchesProto = (protoMsg, testObj, contains) => { | |
return Object.keys(protoMsg).every(key => { | |
if (protoMsg[key] === null) { | |
// Nulls in the proto values can be wild, but must exist | |
return typeof testObj[key] !== 'undefined'; | |
} else if ((protoMsg[key] || {}).constructor === Array | |
&& (testObj[key] || {}).constructor === Array) { | |
return testObj[key].includes(contains); | |
} | |
return protoMsg[key] === testObj[key]; | |
}); | |
}; | |
const WebSocketProxy = new Proxy(window.WebSocket, { | |
construct(wsTarget, wsArgs) { | |
const ws = new wsTarget(...wsArgs); | |
// Configurable hooks | |
ws.hooks = { | |
interceptSend: () => null, | |
listenReceive: () => null | |
}; | |
// WS details | |
ws.details = { | |
_ws: ws, | |
_console: window.console, | |
_wantsActive: true, | |
_hasProblem: false, | |
_isPingChannel: false, | |
_lastPingSent: Date.now(), | |
_lastTickleSent: Date.now(), | |
_protoMessageEvent: undefined, | |
debug() { | |
return { | |
_wantsActive: this._wantsActive, | |
_hasProblem: this._hasProblem, | |
_isPingChannel: this._isPingChannel, | |
_lastPingSent: this._lastPingSent, | |
_lastTickleSent: this._lastTickleSent, | |
}; | |
}, | |
wantsActive() { | |
return this._wantsActive === true; | |
}, | |
wantsAway() { | |
return this._wantsActive === false; | |
}, | |
lastTickleElapsed() { | |
return Date.now() - this._lastTickleSent; | |
}, | |
clearTickleTimestamp() { | |
this._ws.console.debug('Clearing last tickle timestamp'); | |
this._lastTickleSent = 0; | |
}, | |
lastPingElapsed() { | |
return Date.now() - this._lastPingSent; | |
}, | |
isSocketReady() { | |
return this._isPingChannel; | |
}, | |
hasTickleProblem() { | |
// If a tickle hasn't been sent in a while, return false | |
return this.lastTickleElapsed() > (INACTIVE_THRESHOLD_SECS + 120) * 1000; | |
}, | |
hasSocketProblem() { | |
return this.lastPingElapsed() > PING_THRESHOLD_SECS * 1000; | |
}, | |
hasPresenceProblem() { | |
return !(this.wantsActive() || this.wantsAway()); | |
}, | |
checkHasAnyInternalProblem() { | |
return this.hasSocketProblem() && this.hasTickleProblem() && this.hasPresenceProblem(); | |
}, | |
updateState() { | |
if (!this._hasProblem) { | |
if (this.hasTickleProblem()) { | |
this._hasProblem = true; | |
statusDiv.setProblem('A tickle has not been sent lately'); | |
} else if (this.hasSocketProblem()) { | |
this._hasProblem = true; | |
statusDiv.setProblem('Pings are not being sent on time'); | |
} else if (this.hasPresenceProblem()) { | |
this._hasProblem = true; | |
statusDiv.setProblem(\`Manual presence change type is unknown: \${this._wantsActive}\`); | |
} else if (this.isSocketReady()) { | |
// Set the bar color to active or away | |
if (this.wantsActive()) { | |
statusDiv.setActive(); | |
} else if (this.wantsAway()) { | |
statusDiv.setAway(); | |
} | |
} | |
} else if (!this.checkHasAnyInternalProblem()) { | |
this._hasProblem = false; | |
} | |
} | |
}; | |
// Intercept send | |
ws.send = new Proxy(ws.send, { | |
apply(target, thisArg, args) { | |
ws.hooks.interceptSend(args, ws); | |
ws.details.updateState(); | |
return target.apply(thisArg, args); | |
} | |
}); | |
// Listen for messages | |
ws.addEventListener('message', function (event) { | |
ws.hooks.listenReceive(event, ws); | |
}); | |
// Connection opened | |
ws.addEventListener('open', function (event) { | |
ws.console.log(\`Open: \${event.target.url}\`); | |
statusDiv.setBooting(); | |
}); | |
// Connection closed | |
ws.addEventListener('close', function (event) { | |
statusDiv.setBooting(); | |
ws.console.log(\`Close: \${event}\`); | |
errorOverwatch('Socket closed'); | |
}); | |
// Connection error | |
ws.addEventListener('error', function (event) { | |
statusDiv.setBooting(); | |
errorOverwatch(event); | |
}); | |
ws.hooks.listenReceive = (event, currWs) => { | |
const details = currWs.details; | |
if (event && event.data) { | |
let obj = JSON.parse(event.data); | |
if (objMatchesProto(PONG_MSG, obj)) { | |
// Get one pong message event as a prototype | |
if (!details._protoMessageEvent) { | |
mutateEvent(event); | |
event.data = obj; | |
details._protoMessageEvent = event; | |
currWs.console.log('Got proto pong message event', details._protoMessageEvent); | |
} | |
} | |
// Detect manually away | |
else if (objMatchesProto(MANUAL_PRESENCE_AWAY_MSG, obj)) { | |
currWs.console.debug(\`Manual away change detected: \${event.data}\`); | |
details._wantsActive = false; | |
details.updateState(); | |
} | |
// Detect manually active | |
else if (objMatchesProto(MANUAL_PRESENCE_ACTIVE_MSG, obj)) { | |
currWs.console.debug(\`Manual active change detected: \${event.data}\`); | |
details._wantsActive = true; | |
details.updateState(); | |
} | |
// Detect network away | |
else if (objMatchesProto(PRESENCE_AWAY_MSG, obj, userId)) { | |
currWs.console.debug(\`Away update detected: \${event.data}\`); | |
details._wantsActive = true; | |
details.updateState(); | |
} | |
// Detect network active | |
else if (objMatchesProto(PRESENCE_ACTIVE_MSG, obj, userId)) { | |
currWs.console.debug(\`Active update detected: \${event.data}\`); | |
details._wantsActive = true; | |
details.updateState(); | |
} | |
// Listen for user commands | |
else if (objMatchesProto(TEXT_MSG, obj)) { | |
const keys = Object.keys(TEXT_MSG); | |
if (obj[keys[1]] === userId) { | |
const msg = (obj[keys[2]] || '').trim().toLowerCase(); | |
switch (msg) { | |
case 'sigkill!': | |
case 'sigterm!': | |
currWs.console.log('User SIGKILL received. Closing this window.'); | |
document.location.href = 'about:blank'; | |
break; | |
case 'sighup!': | |
currWs.console.log('User SIGHUP received. Reloading this window.'); | |
document.location = document.location; | |
break; | |
} | |
} | |
} | |
} | |
}; | |
ws.hooks.interceptSend = (data, currWs) => { | |
const details = currWs.details; | |
if (data && data.constructor === Array) { | |
let payload = data[0]; | |
let obj = JSON.parse(payload); | |
if (objMatchesProto(PING_MSG, obj)) { | |
if (details._isPingChannel !== true) { | |
details._isPingChannel = true; | |
statusDiv.setBooting(); | |
currWs.console.log(\`Found the ping socket: \${data}\`); | |
} | |
details._lastPingSent = Date.now(); | |
// Hijack ping and replace with tickle | |
if (ws.details.isSocketReady()) { | |
details.updateState(); | |
let tickleElapsed = ws.details.lastTickleElapsed(); | |
if (tickleElapsed > INACTIVE_THRESHOLD_SECS * 1000) { | |
currWs.console.log(\`Last activity \${tickleElapsed / 1000}s ago. | |
Sending activity notification\`); | |
details._lastTickleSent = Date.now(); | |
// Pass by reference | |
const typeKey = Object.keys(PING_MSG)[0]; | |
const idKey = Object.keys(PING_MSG)[1]; | |
obj[typeKey] = TICKLE_MSG[typeKey]; | |
data[0] = JSON.stringify(obj); | |
statusDiv.doPulse(); | |
setTimeout((_ws, id) => { | |
// Send a fake pong into the socket to fulfill the promise | |
const replyKey = Object.keys(PONG_MSG)[1]; | |
const pong = Object.assign({}, PONG_MSG, {[replyKey]: id}); | |
_ws.console.debug('Sending a fake pong', pong); | |
_ws.details._protoMessageEvent.data = pong; | |
_ws.onmessage(_ws.details._protoMessageEvent); | |
}, 500, currWs, obj[idKey]); | |
} | |
} | |
} else if (objMatchesProto(TICKLE_MSG, obj) || objMatchesProto(TYPING_MSG, obj)) { | |
currWs.console.log(\`Genuine activity notification: \${data}\`); | |
details._lastTickleSent = Date.now(); | |
} | |
} | |
} | |
// Save reference | |
window._websockets = window._websockets || []; | |
window._websockets.push(ws); | |
// One console per websocket | |
ws.console = Object.assign({}, protoConsole, {_socketId: window._websockets.length - 1}); | |
return ws; | |
} | |
}); | |
// Catch oddball errors | |
const errorOverwatch = (e) => { | |
if (e && e.target && e.target === 'img') { | |
return; | |
} | |
errorCount++; | |
protoConsole.error(errorCount, e); | |
if (errorCount >= PAGE_ERROR_THRESHOLD) { | |
console.info("Too many errors. Reloading the page."); | |
document.location = document.location; | |
} | |
}; | |
['error', 'unhandledrejection'].forEach((name) => { | |
window.addEventListener(name, errorOverwatch, {capture: true}); | |
}); | |
window.error = errorOverwatch; | |
// Do the magic | |
window.WebSocket = WebSocketProxy; | |
// Debug commands from the console | |
window.slackasa = { | |
ticklenow() { | |
window._websockets.forEach((ws) => { | |
ws.details.clearTickleTimestamp(); | |
}); | |
}, | |
reload() { | |
document.location = document.location; | |
}, | |
listsockets() { | |
window._websockets.forEach((ws) => { | |
ws.console.log(JSON.stringify( | |
Object.assign({_isOpen: ws.readyState === ws.OPEN}, ws.details.debug()), null, 2)); | |
}); | |
}, | |
triggererror() { | |
window.setTimeout(() => { | |
throw new Error("Expected error!") | |
}, 0); | |
}, | |
triggerfatal() { | |
errorCount = PAGE_ERROR_THRESHOLD - 1; | |
this.triggererror(); | |
} | |
}; | |
})(window, (immutableEvent) => { | |
const isStrict = (function () { | |
return !this; | |
})(); | |
if (isStrict) { | |
throw new Error("This won't work in strict mode"); | |
} | |
// Outside of the strict scope | |
Object.defineProperty(immutableEvent, "data", { | |
_data: {}, | |
set: function (obj) { | |
this._data = JSON.stringify(obj); | |
}, | |
get: function () { | |
return this._data; | |
} | |
}) | |
}); | |
`) |
Hey, this seems to break Huddles (can't join when this script is active). Can you reproduce this? Thanks!
Edit: This revision seems to have fixed it, I'll check.
Edit 2: Hmm, does this still work at all?
Edit 3: Not only the backticks need to be unescaped when removing eval, also the $ need to be
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Great. Thanks for making a fix. I'll update things in the coming days. Glad it still pretty much works.