Skip to content

Commit

Permalink
bot-monitor: allow pausing email notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
siddharthvp committed Apr 22, 2024
1 parent 19d325a commit fa2d52a
Show file tree
Hide file tree
Showing 9 changed files with 167 additions and 23 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/toolforge-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 15 additions & 5 deletions bot-monitor/Alert.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: '[email protected]',
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') {
Expand All @@ -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 <b>${this.rule.task}</b> failed to run per the configuration specified at <a href="https://en.wikipedia.org/wiki/Wikipedia:Bot_activity_monitor/Configurations">Wikipedia:Bot activity monitor/Configurations</a>. 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.` +
`<br><br>` +
`If your bot is behaving as expected, then you may want to <a href="https://en.wikipedia.org/wiki/Wikipedia:Bot_activity_monitor/Configurations?action=edit">modify the task configuration instead</a>. Or to unsubscribe from these email notifications, remove the |email= parameter from the {{/task}} template.` +
`<br><br>` +
`To temporarily pause these notifications, click here: https://sdzerobot.toolforge.org/bot-monitor/pause?task=${encodeURIComponent(taskKey)}&webKey=${webKey}` +
`<br><br>` +
`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 (<https://en.wikipedia.org/wiki/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'
Expand Down
71 changes: 56 additions & 15 deletions bot-monitor/AlertsDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
getLastEmailedTime(rule: Rule): Promise<MwnDate>;
saveLastEmailedTime(rule: Rule): Promise<void>;
getPausedTillTime(bot: string, webKey: string): Promise<MwnDate>;
setPausedTillTime(bot: string, webKey: string, pauseTill?: Date): Promise<number>;
}

// 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<void> {
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<MwnDate> {
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<void> {
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<number> {
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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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();
8 changes: 8 additions & 0 deletions bot-monitor/botmonitor.sql
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 18 additions & 0 deletions bot-monitor/web-endpoint.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<h2>Pausing failure notifications for {{task}}</h2>
{{#if current}}
<form method="post">
<label>{{{current}}}</label>
<input type="hidden" name="task" value="{{task}}">
<input type="hidden" name="webKey" value="{{webKey}}">
<input type="hidden" name="unpause" value="1">
<input type="submit" value="Unpause">
</form>
{{/if}}
<br>
<form method="post">
<input type="hidden" name="task" value="{{task}}">
<input type="hidden" name="webKey" value="{{webKey}}">
<label for="till">Pause notifications till:</label>
<input id="till" name="date" type="date" value="{{dateVal}}" required>
<input type="submit" value="Pause">
</form>
61 changes: 61 additions & 0 deletions bot-monitor/web-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
const { date, unpause } = req.body as Record<string, string>;
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 = `<span style="color: green; font-weight: bold">Successfully paused notifications till ${tillDate.format('D MMMM YYYY')} (UTC).</span>`;
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 = `<span style="color: green; font-weight: bold">Successfully unpaused notifications.</span>`;
} 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;
6 changes: 5 additions & 1 deletion webservice/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
2 changes: 1 addition & 1 deletion webservice/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions webservice/route-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}

0 comments on commit fa2d52a

Please sign in to comment.