diff --git a/src/UserscriptTools/ChatApi.ts b/src/UserscriptTools/ChatApi.ts index 9fbb5a4..3301135 100644 --- a/src/UserscriptTools/ChatApi.ts +++ b/src/UserscriptTools/ChatApi.ts @@ -1,5 +1,4 @@ import { Store, Cached } from './Store'; -import { withTimeout } from '../shared'; interface ChatWSAuthResponse { url: string; @@ -33,8 +32,6 @@ export class ChatApi { private readonly roomId: number; private readonly nattyId = 6817005; - private websocket: WebSocket | null = null; - public constructor( chatUrl = 'https://chat.stackoverflow.com', roomId = 111347 @@ -167,68 +164,33 @@ export class ChatApi { }); } - public async initWS(): Promise { - const l = await this.getLParam(); + public async getFinalUrl(): Promise { const url = await this.getWsUrl(); + const l = await this.getLParam(); - const ws = `${url}?l=${l}`; - this.websocket = new WebSocket(ws); - - if (Store.dryRun) { - console.log('Initialised chat WebSocket at', ws, this.websocket); - } + return `${url}?l=${l}`; } - private closeWS(): void { - // websocket already closed - if (!this.websocket) return; - - this.websocket.close(); - this.websocket = null; - - if (Store.dryRun) { - console.log('Chat WebSocket connection closed.'); - } - } + public reportReceived(event: MessageEvent): number[] { + const data = JSON.parse(event.data) as ChatWsMessage; - public async waitForReport(postId: number): Promise { - if (!this.websocket || this.websocket.readyState > 1) { - this.websocket = null; + return data[`r${this.roomId}`].e + ?.filter(({ event_type, user_id }) => { + // interested in new messages posted by Natty + return event_type === 1 && user_id === this.nattyId; + }) + .map(item => { + const { content } = item; - if (Store.dryRun) { - console.log('Failed to connect to chat WS.'); - } + if (Store.dryRun) { + console.log('New message posted by Natty on room', this.roomId, item); + } - return; - } + const matchRegex = /stackoverflow\.com\/a\/(\d+)/; + const id = matchRegex.exec(content)?.[1]; - await withTimeout( - 10_000, - new Promise(resolve => { - this.websocket?.addEventListener('message', (event: MessageEvent) => { - const data = JSON.parse(event.data) as ChatWsMessage; - - data[`r${this.roomId}`].e - ?.filter(({ event_type, user_id }) => { - // interested in new messages posted by Natty - return event_type === 1 && user_id === this.nattyId; - }) - .forEach(item => { - const { content } = item; - - if (Store.dryRun) { - console.log('New message posted by Natty on room', this.roomId, item); - } - - const matchRegex = /stackoverflow\.com\/a\/(\d+)/; - const id = matchRegex.exec(content)?.[1]; - if (Number(id) !== postId) return; - - resolve(); - }); - }); - }) - ).finally(() => this.closeWS()); + return Number(id); + }) || []; } private getChannelFKey(): Promise { diff --git a/src/UserscriptTools/MetaSmokeAPI.ts b/src/UserscriptTools/MetaSmokeAPI.ts index eb668c3..bd4016e 100644 --- a/src/UserscriptTools/MetaSmokeAPI.ts +++ b/src/UserscriptTools/MetaSmokeAPI.ts @@ -4,14 +4,11 @@ import { PostType, delay, getFormDataFromObject, - withTimeout, } from '../shared'; import { Modals, Input, Buttons } from '@userscripters/stacks-helpers'; import { displayToaster, page } from '../AdvancedFlagging'; import Reporter from './Reporter'; - -const metasmokeReportedMessage = 'Post reported to Smokey'; -const metasmokeFailureMessage = 'Failed to report post to Smokey'; +import { WebsocketUtils } from './WebsocketUtils'; interface MetaSmokeApiItem { id: number; @@ -48,8 +45,18 @@ export class MetaSmokeAPI extends Reporter { private static readonly filter = 'GGJFNNKKJFHFKJFLJLGIJMFIHNNJNINJ'; private static metasmokeIds: MetasmokeData = {}; - private websocket: WebSocket | null = null; + private readonly reportMessage = 'Post reported to Smokey'; + private readonly failureMessage = 'Failed to report post to Smokey'; + private readonly wsUrl = 'wss://metasmoke.erwaysoftware.com/cable'; + private readonly wsAuth = JSON.stringify({ + identifier: JSON.stringify({ + channel: 'ApiChannel', + key: MetaSmokeAPI.appKey, + events: 'posts#create' + }), + command: 'subscribe' + }); constructor( id: number, @@ -71,86 +78,34 @@ export class MetaSmokeAPI extends Reporter { MetaSmokeAPI.accessToken = await MetaSmokeAPI.getUserKey(); } - private initWS(): void { - this.websocket = new WebSocket(this.wsUrl); + public reportReceived(event: MessageEvent): number[] { + const data = JSON.parse(event.data) as MetasmokeWsMessage; - const auth = JSON.stringify({ - identifier: JSON.stringify({ - channel: 'ApiChannel', - key: MetaSmokeAPI.appKey, - events: 'posts#create' - }), - command: 'subscribe' - }); - - this.websocket.addEventListener('open', () => { - this.websocket?.send(auth); - }); + // https://github.com/Charcoal-SE/userscripts/blob/master/sim/sim.user.js#L381-L400 + if (data.type) return []; // not interested if (Store.dryRun) { - console.log('MS WebSocket initialised.'); + console.log('New post reported to Smokey', data); } - } - private closeWS(): void { - // websocket already closed (perhaps connection failed) - if (!this.websocket || this.websocket.readyState > 1) { - this.websocket = null; + const { + object, + event_class: evClass, + event_type: type + } = data.message; - if (Store.dryRun) { - console.log('Failed to connect to metasmoke WS.'); - } + // not interested + if (type !== 'create' || evClass !== 'Post') return []; - return; - } + const link = object.link; + const url = new URL(link, location.href); - this.websocket.close(); - this.websocket = null; + const postId = Number(/\d+/.exec(url.pathname)?.[0]); - if (Store.dryRun) { - console.log('MS WebSocket connection closed.'); - } - } - - private async waitForReport(): Promise { - if (!this.websocket) return; - - await withTimeout( - 10_000, - new Promise(resolve => { - this.websocket?.addEventListener('message', (event: MessageEvent) => { - const data = JSON.parse(event.data) as MetasmokeWsMessage; - - // https://github.com/Charcoal-SE/userscripts/blob/master/sim/sim.user.js#L381-L400 - if (data.type) return; // not interested - - if (Store.dryRun) { - console.log('New post reported to Smokey', data); - } - - const { - object, - event_class: evClass, - event_type: type - } = data.message; + // different sites + if (url.host !== location.host) return []; - // not interested - if (type !== 'create' || evClass !== 'Post') return; - - const link = object.link; - const url = new URL(link, location.href); - - const postId = Number(/\d+/.exec(url.pathname)?.[0]); - - if ( - url.host !== location.host // different sites - || postId !== this.id // different posts - ) return; - - resolve(); - }); - }) - ).finally(() => this.closeWS()); + return [ postId ]; } private static getMetasmokeTokenPopup(): HTMLElement { @@ -337,7 +292,7 @@ export class MetaSmokeAPI extends Reporter { if (!reportRequest.ok || requestResponse !== 'OK') { console.error(`Failed to report post ${this.smokeyId} to Smokey`, requestResponse); - throw new Error(metasmokeFailureMessage); + throw new Error(this.failureMessage); } } @@ -371,14 +326,15 @@ export class MetaSmokeAPI extends Reporter { // not reported, feedback is tpu AND the post isn't deleted => report it! if (!this.smokeyId && feedback === 'tpu-' && !this.deleted) { // see: https://chat.stackexchange.com/transcript/message/65076878 - this.initWS(); + const wsUtils = new WebsocketUtils(this.wsUrl, this.id, this.wsAuth); await this.reportRedFlag(); - await this.waitForReport(); + await wsUtils.waitForReport(event => this.reportReceived(event)); // https://chat.stackexchange.com/transcript/message/65097399 + // wait 3 seconds so that SD can start watching for post deletion await new Promise(resolve => setTimeout(resolve, 3 * 1000)); - return metasmokeReportedMessage; + return this.reportMessage; } else if (!accessToken || !this.smokeyId) { // user hasn't authenticated or the post hasn't been reported => don't send feedback return ''; diff --git a/src/UserscriptTools/NattyApi.ts b/src/UserscriptTools/NattyApi.ts index e7dbf0e..13369ec 100644 --- a/src/UserscriptTools/NattyApi.ts +++ b/src/UserscriptTools/NattyApi.ts @@ -3,6 +3,7 @@ import { AllFeedbacks } from '../shared'; import { page } from '../AdvancedFlagging'; import Reporter from './Reporter'; import Page from './Page'; +import { WebsocketUtils } from './WebsocketUtils'; const dayMillis = 1000 * 60 * 60 * 24; const nattyFeedbackUrl = 'https://logs.sobotics.org/napi-1.1/api/stored/'; @@ -92,9 +93,14 @@ export class NattyAPI extends Reporter { // post is deleted immediately. As a result, it // isn't reported on time to Natty. if (StackExchange.options.user.isModerator) { - await this.chat.initWS(); + // init websocket + const url = await this.chat.getFinalUrl(); + const wsUtils = new WebsocketUtils(url, this.id); + await this.chat.sendMessage(this.reportMessage); - await this.chat.waitForReport(this.id); + + // wait until the report is received + await wsUtils.waitForReport(event => this.chat.reportReceived(event)); } else { await this.chat.sendMessage(this.reportMessage); } diff --git a/src/UserscriptTools/Post.ts b/src/UserscriptTools/Post.ts index 8dba4be..4ed13ba 100644 --- a/src/UserscriptTools/Post.ts +++ b/src/UserscriptTools/Post.ts @@ -1,6 +1,6 @@ import { displaySuccessFlagged, displayToaster, getFlagToRaise } from '../AdvancedFlagging'; import { Flags } from '../FlagTypes'; -import { getSvg, PostType, FlagNames, getFormDataFromObject, addXHRListener } from '../shared'; +import { getSvg, PostType, FlagNames, getFormDataFromObject, addXHRListener, delay } from '../shared'; import { CopyPastorAPI } from './CopyPastorAPI'; import { GenericBotAPI } from './GenericBotAPI'; import { MetaSmokeAPI } from './MetaSmokeAPI'; @@ -20,6 +20,7 @@ interface StackExchangeFlagResponse { interface StackExchangeDeleteResponse { Success: boolean; Message: string; + Refresh: boolean; } export interface Reporters { @@ -148,6 +149,9 @@ export default class Post { // flag changes the state of the post // => reload the page if (response.ResultChangedState) { + // wait 1 second before reloading + await delay(1000); + location.reload(); } @@ -201,6 +205,12 @@ export default class Post { throw json.Message.toLowerCase(); } + + if (json.Refresh) { + await delay(1500); + + location.reload(); + } } public async comment(text: string): Promise { diff --git a/src/UserscriptTools/WebsocketUtils.ts b/src/UserscriptTools/WebsocketUtils.ts new file mode 100644 index 0000000..67941f2 --- /dev/null +++ b/src/UserscriptTools/WebsocketUtils.ts @@ -0,0 +1,108 @@ +import { Store } from './Store'; + +export class WebsocketUtils { + public websocket: WebSocket | null = null; + + private readonly url: string; + private readonly id: number; + private readonly timeout: number; + // message sent when the websocket opens + // for authentication + private readonly auth: string; + + constructor( + url: string, + // id of the post to watch + id: number, + auth?: string, + timeout = 1e4 + ) { + this.url = url; + this.id = id; + this.auth = auth || ''; + this.timeout = timeout; + + this.initWebsocket(); + } + + public async waitForReport( + // called every time a new WS message is received + // returns the ids of the posts reported to the bot + callback: (event: MessageEvent) => number[] + ): Promise { + if (!this.websocket || this.websocket.readyState > 1) { + this.websocket = null; + + if (Store.dryRun) { + console.log('Failed to connect to', this.url, 'WebSocket'); + } + + return; + } + + await this.withTimeout( + this.timeout, + new Promise(resolve => { + this.websocket?.addEventListener( + 'message', + (event: MessageEvent) => { + const ids = callback(event); + + if (Store.dryRun) { + console.log('New message from', this.url, event.data); + console.log('Comparing', ids, 'to', this.id); + } + + if (ids.includes(this.id)) resolve(); + }); + }) + ); + } + + private initWebsocket(): void { + this.websocket = new WebSocket(this.url); + + if (this.auth) { + this.websocket.addEventListener('open', () => { + this.websocket?.send(this.auth); + }); + } + + if (Store.dryRun) { + console.log('WebSocket', this.url, 'initialised.'); + } + } + + private closeWebsocket(): void { + // websocket already closed + if (!this.websocket) return; + + this.websocket.close(); + this.websocket = null; + + if (Store.dryRun) { + console.log('Closed connection to', this.url); + } + } + + private async withTimeout(millis: number, promise: Promise): Promise { + let time: NodeJS.Timeout | undefined; + + const timeout = new Promise(resolve => { + time = setTimeout(() => { + if (Store.dryRun) { + console.log('WebSocket connection timeouted after', millis, 'ms'); + } + + resolve(); + }, millis); + }); + + await Promise + .race([ promise, timeout ]) + .finally(() => { + clearTimeout(time); + this.closeWebsocket(); + }); + } +} \ No newline at end of file diff --git a/src/review.ts b/src/review.ts index e01712f..9749634 100644 --- a/src/review.ts +++ b/src/review.ts @@ -1,4 +1,4 @@ -import { addXHRListener } from './shared'; +import { addXHRListener, delay } from './shared'; import { isDone } from './AdvancedFlagging'; import { MetaSmokeAPI } from './UserscriptTools/MetaSmokeAPI'; @@ -41,10 +41,8 @@ async function runOnNewTask(xhr: XMLHttpRequest): Promise { const post = cached || new Post(element); - while (!isDone) { - // eslint-disable-next-line no-await-in-loop - await new Promise(resolve => setTimeout(resolve, 200)); - } + // eslint-disable-next-line no-await-in-loop + while (!isDone) await delay(200); // update info on reporters const url = `//stackoverflow.com/a/${post.id}`; diff --git a/src/shared.ts b/src/shared.ts index 8a4e3eb..4c82536 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -180,22 +180,3 @@ export function getHumanFromDisplayName(displayName: Flags): HumanFlags { return flags[displayName] || ''; } - -export async function withTimeout(millis: number, promise: Promise): Promise { - let time: NodeJS.Timeout | undefined; - - const timeout = new Promise(resolve => { - time = setTimeout(() => { - if (Store.dryRun) console.log('Promise timeouted after', millis, 'ms'); - - resolve(); - }, millis); - }); - - await Promise.race([ - promise, - timeout - ]).finally(() => { - if (time) clearTimeout(time); - }); -} \ No newline at end of file