diff --git a/.github/workflows/toolforge-deploy.yml b/.github/workflows/toolforge-deploy.yml index 895bfb7..3636377 100644 --- a/.github/workflows/toolforge-deploy.yml +++ b/.github/workflows/toolforge-deploy.yml @@ -67,7 +67,7 @@ jobs: exit 1; fi; npm restart; - elif [[ "$(git diff --name-only HEAD HEAD@{1} | grep -c "web-endpoint")" -gt 0 || "$(git rev-list --format=%B --max-count=1 HEAD)" == *"!restart"* || "$(git rev-list --format=%B --max-count=1 HEAD)" == *"!web-restart"* ]]; then + elif [[ "$(git diff --name-only HEAD HEAD@{1} | grep -c "web-endpoint")" -gt 0 || "$(git diff --name-only HEAD HEAD@{1} | grep -c "hbs")" -gt 0 || "$(git rev-list --format=%B --max-count=1 HEAD)" == *"!restart"* || "$(git rev-list --format=%B --max-count=1 HEAD)" == *"!web-restart"* ]]; then echo "Restarting SDZeroBot webservice ..."; cd /data/project/sdzerobot/www/js && npm restart; fi; diff --git a/bot-monitor/Alert.ts b/bot-monitor/Alert.ts index 2c6e802..6e10e64 100644 --- a/bot-monitor/Alert.ts +++ b/bot-monitor/Alert.ts @@ -1,5 +1,6 @@ import { argv, bot, log, mailTransporter, Mwn } from "../botbase"; import {Rule, RuleError, Monitor, subtractFromNow, alertsDb} from "./index"; +import * as crypto from "crypto"; export class Alert { rule: Rule @@ -60,17 +61,18 @@ export class Alert { log(`[i] Sending email for "${this.name}" to ${this.rule.email}`); let subject = `[${this.rule.bot}] ${this.rule.task} failure`; + const webKey = crypto.randomBytes(32).toString('hex'); if (this.rule.email.includes('@')) { await mailTransporter.sendMail({ from: 'tools.sdzerobot@tools.wmflabs.org', to: this.rule.email, subject: subject, - html: this.getEmailBodyHtml(), + html: this.getEmailBodyHtml(webKey), }); } else { await new bot.User(this.rule.email).email( subject, - this.getEmailBodyPlain(), + this.getEmailBodyPlain(webKey), {ccme: true} ).catch(err => { if (err.code === 'notarget') { @@ -86,18 +88,26 @@ export class Alert { }); } - getEmailBodyHtml(): string { + getEmailBodyHtml(webKey: string): string { + const taskKey = `${this.rule.bot}: ${this.rule.task}`; return `${this.rule.bot}'s task ${this.rule.task} failed to run per the configuration specified at Wikipedia:Bot activity monitor/Configurations. Detected only ${this.actions} ${this.rule.action === 'edit' ? 'edit' : `"${this.rule.action}" action`}s in the last ${this.rule.duration}, whereas at least ${this.rule.minEdits} were expected.` + `

` + `If your bot is behaving as expected, then you may want to modify the task configuration instead. Or to unsubscribe from these email notifications, remove the |email= parameter from the {{/task}} template.` + `

` + + `To temporarily pause these notifications, click here: https://sdzerobot.toolforge.org/bot-monitor/pause?task=${encodeURIComponent(taskKey)}&webKey=${webKey}` + + `

` + `Thanks!`; } - getEmailBodyPlain(): string { + getEmailBodyPlain(webKey: string): string { + const taskKey = `${this.rule.bot}: ${this.rule.task}`; return `${this.rule.bot}'s task "${this.rule.task}" failed to run per the configuration specified at Wikipedia:Bot activity monitor/Configurations (). Detected only ${this.actions} ${this.rule.action === 'edit' ? 'edit' : `"${this.rule.action}" action`}s in the last ${this.rule.duration}, whereas at least ${this.rule.minEdits} were expected.` + `\n\n` + - `If your bot is behaving as expected, then you may want to modify the task configuration instead. Or to unsubscribe from these email notifications, remove the |email= parameter from the {{/task}} template. Thanks!`; + `If your bot is behaving as expected, then you may want to modify the task configuration instead. Or to unsubscribe from these email notifications, remove the |email= parameter from the {{/task}} template.` + + `\n\n` + + `To temporarily pause these notifications, click here: https://sdzerobot.toolforge.org/bot-monitor/pause?task=${encodeURIComponent(taskKey)}&webKey=${webKey}` + + `\n\n` + + `Thanks!`; } // static pingpage = 'Wikipedia:Bot activity monitor/Pings' diff --git a/bot-monitor/AlertsDb.ts b/bot-monitor/AlertsDb.ts index 36363eb..ea0abd8 100644 --- a/bot-monitor/AlertsDb.ts +++ b/bot-monitor/AlertsDb.ts @@ -3,43 +3,78 @@ import {MwnDate} from "mwn"; import {getKey, Rule} from "./Rule"; import {bot, toolsdb} from "../botbase"; import * as fs from "fs/promises"; +import * as crypto from "crypto"; import {getRedisInstance, Redis} from "../redis"; -import {createLocalSSHTunnel} from "../utils"; -import {TOOLS_DB_HOST} from "../db"; +import {ResultSetHeader} from "mysql2"; +import {CustomError} from "./web-endpoint"; interface AlertsDb { connect(): Promise; getLastEmailedTime(rule: Rule): Promise; saveLastEmailedTime(rule: Rule): Promise; + getPausedTillTime(bot: string, webKey: string): Promise; + setPausedTillTime(bot: string, webKey: string, pauseTill?: Date): Promise; } +// To allow user to disable checks for some time period: +// Generate a secret for each email sent and persist in db +// Provide a disable link with secret as query string in the email +// When clicked, check if secret is valid and disable notifications. + class MariadbAlertsDb implements AlertsDb { db: toolsdb; async connect(): Promise { - await createLocalSSHTunnel(TOOLS_DB_HOST); this.db = new toolsdb('botmonitor'); - await this.db.run(` - CREATE TABLE IF NOT EXISTS alerts( - name VARCHAR(255) PRIMARY KEY, - lastEmailed TIMESTAMP - ) - `); } async getLastEmailedTime(rule: Rule): Promise { - let data = await this.db.query( - `SELECT lastEmailed FROM alerts WHERE name = ?`, - [ getKey(rule, 250) ] + let data = await this.db.query(` + SELECT lastEmailed, paused FROM alerts + WHERE name = ? + `, [ getKey(rule, 250) ] ); - return data[0] ? new bot.Date(data[0].lastEmailed) : new bot.Date(0); + if (data[0]) { + if (data[0].paused && new bot.Date(data[0].paused).isAfter(new Date())) { + return new bot.Date(data[0].paused); + } + return new bot.Date(data[0].lastEmailed); + } else { + return new bot.Date(0); + } } async saveLastEmailedTime(rule: Rule): Promise { await this.db.run( - `REPLACE INTO alerts (name, lastEmailed) VALUES(?, UTC_TIMESTAMP())`, - [ getKey(rule, 250) ] + `REPLACE INTO alerts (name, lastEmailed, webKey) VALUES(?, UTC_TIMESTAMP(), ?)`, + [ getKey(rule, 250), crypto.randomBytes(32).toString('hex') ] ); } + + async getPausedTillTime(name: string, webKey: string) { + let data = await this.db.query(` + SELECT webKey, paused FROM alerts + WHERE name = ? + `, [name]); + if (!data[0]) { + throw new CustomError(404, 'No such bot task is configured.'); + } + if (data[0].webKey !== webKey) { + throw new CustomError(403, `Invalid or expired webKey. Please use the link from the latest SDZeroBot email.`); + } + if (data[0].paused) { + return new bot.Date(data[0].paused); + } + } + + async setPausedTillTime(name: string, webKey: string, pauseTill?: MwnDate): Promise { + const result = await this.db.run(` + UPDATE alerts + SET paused = ? + WHERE name = ? + AND webKey = ? + `, [pauseTill ? pauseTill.format('YYYY-MM-DD') : null, name, webKey]); + return (result?.[0] as ResultSetHeader)?.affectedRows; + } } class CassandraAlertsDb implements AlertsDb { @@ -63,6 +98,8 @@ class CassandraAlertsDb implements AlertsDb { [ getKey(rule) ] ); } + async getPausedTillTime(name: string, webKey: string) { return new bot.Date(0); } + async setPausedTillTime(bot: string, webKey: string, pauseTill: MwnDate) { return -1; } } class FileSystemAlertsDb implements AlertsDb { @@ -85,6 +122,8 @@ class FileSystemAlertsDb implements AlertsDb { // only needed on the last save, but done everytime anyway await fs.writeFile(this.file, JSON.stringify(this.data)); } + async getPausedTillTime(name: string, webKey: string) { return new bot.Date(0); } + async setPausedTillTime(bot: string, webKey: string, pauseTill: MwnDate) { return -1; } } class RedisAlertsDb implements AlertsDb { @@ -99,6 +138,8 @@ class RedisAlertsDb implements AlertsDb { async saveLastEmailedTime(rule: Rule) { await this.redis.hset('botmonitor-last-emailed', getKey(rule), new bot.date().toISOString); } + async getPausedTillTime(name: string, webKey: string) { return new bot.Date(0); } + async setPausedTillTime(bot: string, webKey: string, pauseTill: MwnDate) { return -1; } } export const alertsDb: AlertsDb = new MariadbAlertsDb(); diff --git a/bot-monitor/botmonitor.sql b/bot-monitor/botmonitor.sql new file mode 100644 index 0000000..404c0a5 --- /dev/null +++ b/bot-monitor/botmonitor.sql @@ -0,0 +1,8 @@ + +CREATE TABLE alerts( + name VARCHAR(255) PRIMARY KEY, + lastEmailed TIMESTAMP +); + +ALTER TABLE alerts ADD webKey CHAR(128); +ALTER TABLE alerts ADD paused timestamp null; diff --git a/bot-monitor/web-endpoint.hbs b/bot-monitor/web-endpoint.hbs new file mode 100644 index 0000000..aa27ba5 --- /dev/null +++ b/bot-monitor/web-endpoint.hbs @@ -0,0 +1,18 @@ +

Pausing failure notifications for {{task}}

+{{#if current}} +
+ + + + + +
+{{/if}} +
+
+ + + + + +
diff --git a/bot-monitor/web-endpoint.ts b/bot-monitor/web-endpoint.ts new file mode 100644 index 0000000..eaae109 --- /dev/null +++ b/bot-monitor/web-endpoint.ts @@ -0,0 +1,61 @@ +import * as express from "express"; +import {alertsDb} from "./AlertsDb"; +import {bot} from "../botbase"; + +const router = express.Router(); + +alertsDb.connect(); + +router.all('/pause', async (req, res, next) => { + try { + const { task, webKey } = req.query as Record; + const { date, unpause } = req.body as Record; + if (!webKey || !task) { + return next(new CustomError(400, "Missing one of required query params: task, webKey")); + } + + let current = ''; + let dateVal = ''; + if (date) { // POST + const tillDate = new bot.Date(date); + const rowsUpdated = await alertsDb.setPausedTillTime(task, webKey, tillDate); + if (!rowsUpdated) { + return next(new CustomError(403, "Unauthorized")); + } + current = `Successfully paused notifications till ${tillDate.format('D MMMM YYYY')} (UTC).`; + dateVal = tillDate.format('YYYY-MM-DD'); + } else if (unpause) { // POST + const rowsUpdated = await alertsDb.setPausedTillTime(task, webKey); + if (!rowsUpdated) { + return next(new CustomError(403, "Unauthorized")); + } + current = `Successfully unpaused notifications.`; + } else { // GET + let pausedTill = await alertsDb.getPausedTillTime(task, webKey); + if (pausedTill) { + current = `Notifications are currently paused till ${pausedTill.format('D MMMM YYYY')} (UTC).`; + dateVal = pausedTill.format('YYYY-MM-DD'); + } + } + + return res.render('bot-monitor/web-endpoint', { + task, + webKey, + current, + dateVal + }); + } catch (e) { + next(e); + } +}); + +export class CustomError extends Error { + status: number; + constructor(status: number, msg: string) { + super(msg); + this.status = status; + } +} + + +export default router; diff --git a/webservice/app.ts b/webservice/app.ts index 430e7db..885aed7 100644 --- a/webservice/app.ts +++ b/webservice/app.ts @@ -8,7 +8,7 @@ import * as cors from 'cors'; // All paths to SDZeroBot files must be via ../../SDZeroBot rather than via ../ // The latter will work locally but not when inside toolforge www/js directory! -import { bot, Mwn } from "../../SDZeroBot/botbase"; +import {bot, logFullError, Mwn} from "../../SDZeroBot/botbase"; import { humanDate } from "../../mwn/build/log"; import { registerRoutes } from "./route-registry"; @@ -64,6 +64,10 @@ app.use(function (err, req, res, next) { // render the error page res.status(err.status || 500); res.render('webservice/views/error'); + + if (!err.status || err.status === 500) { + logFullError(err, false); + } }); export default app; diff --git a/webservice/package.json b/webservice/package.json index 7307370..1dcdac5 100644 --- a/webservice/package.json +++ b/webservice/package.json @@ -4,7 +4,7 @@ "scripts": { "start": "env WEB=true /data/project/sdzerobot/bin/node server.js", "tunnels": "node tunnels.js", - "test": "nodemon server.ts", + "test": "nodemon server.ts --watch .. --ext ts,js,hbs", "debug": "node --require ts-node/register server.js", "restart": "webservice --backend kubernetes node18 restart", "logs": "kubectl logs deployment/sdzerobot" diff --git a/webservice/route-registry.ts b/webservice/route-registry.ts index 3e73f78..da25c73 100644 --- a/webservice/route-registry.ts +++ b/webservice/route-registry.ts @@ -8,6 +8,7 @@ import gansRouter from '../../SDZeroBot/most-gans/web-endpoint'; import articleSearchRouter from './routes/articlesearch'; import dykRouter from '../../SDZeroBot/dyk-counts/web-endpoint'; import gitsync from "./routes/gitsync"; +import botMonitorRouter from '../../SDZeroBot/bot-monitor/web-endpoint' export function registerRoutes(app: express.Router) { app.use('/', indexRouter); @@ -19,4 +20,5 @@ export function registerRoutes(app: express.Router) { app.use('/articlesearch', articleSearchRouter); app.use('/dyk', dykRouter); app.use('/gitsync', gitsync); + app.use('/bot-monitor', botMonitorRouter); }