Skip to content

Commit

Permalink
bot-monitor: support for email alerts
Browse files Browse the repository at this point in the history
  • Loading branch information
siddharthvp committed Jul 16, 2023
1 parent d0a9af9 commit 042697f
Show file tree
Hide file tree
Showing 9 changed files with 210 additions and 71 deletions.
89 changes: 58 additions & 31 deletions bot-monitor/Alert.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { argv, bot, log, Mwn } from "../botbase";
import {Rule, RuleError, Monitor} from "./index";
import { argv, bot, log, mailTransporter, Mwn } from "../botbase";
import {Rule, RuleError, Monitor, subtractFromNow, alertsDb} from "./index";

export class Alert {
rule: Rule
Expand All @@ -11,9 +11,11 @@ export class Alert {
}

async alert() {
if (!argv.dry && this.rule.alertPage) {
await this.alertTalkPage();
}
if (argv.dry) return;
await Promise.all([
this.rule.alertPage && this.alertTalkPage(),
this.rule.email && this.alertEmail(),
]);
}

async alertTalkPage() {
Expand All @@ -27,7 +29,7 @@ export class Alert {
log(`[i] Notifying for ${this.rule.bot}`);
await page.newSection(
header,
this.getMessage() + ' – ~~~~',
this.getTalkMessage() + ' – ~~~~',
{ redirect: true, nocreate: true }
).catch(err => {
if (err.code === 'missingtitle') {
Expand All @@ -38,7 +40,7 @@ export class Alert {
});
}

getMessage() {
getTalkMessage() {
return Mwn.template('subst:Wikipedia:Bot activity monitor/Notification', {
bot: this.rule.bot,
task: this.rule.task,
Expand All @@ -49,30 +51,55 @@ export class Alert {
});
}

// async alert() {
// if (this.rule.alertMode === 'talkpage') {
// await this.alertTalkPage();
// } else if (this.rule.alertMode === 'email') {
// await this.alertEmail();
// } else if (this.rule.alertMode === 'ping') {
// await this.alertPing();
// } else {
// throw new RuleError(`Invalid alert mode: ${this.rule.alertMode}: must be "talkpage", "email" or "ping"`);
// }
// }
// async alertEmail() {
// await new bot.user(this.rule.emailUser).email(
// this.getHeader(),
// this.getMessage(),
// {ccme: true}
// ).catch(err => {
// if (err.code === 'notarget') {
// throw new RuleError(`Invalid username for email: ${this.rule.emailUser}`);
// } else if (err.code === 'nowikiemail') {
// throw new RuleError(`Email is disabled for ${this.rule.emailUser}`);
// } else throw err;
// });
// }
async alertEmail() {
let lastAlertedTime = await alertsDb.getLastEmailedTime(this.rule);
if (lastAlertedTime.isAfter(subtractFromNow(this.rule.duration, 1))) {
log(`[i] Aborting email for "${this.name}" because one was already sent in the last ${this.rule.duration}`);
return;
}
log(`[i] Sending email for "${this.name}" to ${this.rule.email}`);
let subject = `[${this.rule.bot}] ${this.rule.task} failure`;

if (this.rule.email.includes('@')) {
await mailTransporter.sendMail({
from: '[email protected]',
to: this.rule.email,
subject: subject,
html: this.getEmailBodyHtml(),
});
} else {
await new bot.User(this.rule.email).email(
subject,
this.getEmailBodyPlain(),
{ccme: true}
).catch(err => {
if (err.code === 'notarget') {
throw new RuleError(`Invalid username for email: ${this.rule.email}`);
} else if (err.code === 'nowikiemail') {
throw new RuleError(`Email is disabled for ${this.rule.email}`);
} else throw err;
});
}
await alertsDb.saveLastEmailedTime(this.rule).catch(async () => {
// Try that again, we don't want to send duplicate emails!
await alertsDb.saveLastEmailedTime(this.rule);
});
}

getEmailBodyHtml(): string {
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>` +
`Thanks!`;
}

getEmailBodyPlain(): string {
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!`;
}

// static pingpage = 'Wikipedia:Bot activity monitor/Pings'
// async alertPing() {
// let pingUser = this.rule.pingUser || await getBotOperator(this.rule.bot) || this.rule.bot;
Expand Down
104 changes: 104 additions & 0 deletions bot-monitor/AlertsDb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {Cassandra} from "../cassandra";
import {MwnDate} from "mwn";
import {getKey, Rule} from "./Rule";
import {bot, toolsdb} from "../botbase";
import * as fs from "fs/promises";
import {getRedisInstance, Redis} from "../redis";
import {createLocalSSHTunnel} from "../utils";
import {TOOLS_DB_HOST} from "../db";

interface AlertsDb {
connect(): Promise<void>;
getLastEmailedTime(rule: Rule): Promise<MwnDate>;
saveLastEmailedTime(rule: Rule): Promise<void>;
}

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),
lastEmailed TIMESTAMP
)
`);
}

async getLastEmailedTime(rule: Rule): Promise<MwnDate> {
let data = await this.db.query(
`SELECT lastEmailed FROM alerts WHERE name = ?`,
[ getKey(rule, 250) ]
);
return data[0] ? new bot.Date(data[0].lastEmailed) : new bot.Date(0);
}

async saveLastEmailedTime(rule: Rule): Promise<void> {
await this.db.run(
`REPLACE INTO alerts (name, lastEmailed) VALUES(?, NOW())`,
[ getKey(rule, 250) ]
);
}
}

class CassandraAlertsDb implements AlertsDb {
cs: Cassandra = new Cassandra();

async connect() {
await this.cs.connect();
}

async getLastEmailedTime(rule: Rule): Promise<MwnDate> {
let data= await this.cs.execute(
'SELECT lastEmailed FROM botMonitor WHERE name = ?',
[ getKey(rule) ]
);
return new bot.date(data.rows[0].get('lastEmailed'));
}

async saveLastEmailedTime(rule: Rule) {
await this.cs.execute(
'UPDATE botMonitor SET lastEmailed = toTimestamp(now()) WHERE name = ?',
[ getKey(rule) ]
);
}
}

class FileSystemAlertsDb implements AlertsDb {
file = 'alerts_db.json';
data: Record<string, any>;

async connect() {
this.data = JSON.parse((await fs.readFile(this.file)).toString());
}

async getLastEmailedTime(rule: Rule): Promise<MwnDate> {
return new bot.date(this.data[getKey(rule)].lastEmailed);
}
async saveLastEmailedTime(rule: Rule): Promise<void> {
if (!this.data[getKey(rule)]) {
this.data[getKey(rule)] = {};
}
this.data[getKey(rule)].lastEmailed = new bot.date().toISOString();

// only needed on the last save, but done everytime anyway
await fs.writeFile(this.file, JSON.stringify(this.data));
}
}

class RedisAlertsDb implements AlertsDb {
redis: Redis;
async connect() {
this.redis = getRedisInstance();
}

async getLastEmailedTime(rule: Rule) {
return new bot.date(await this.redis.hget('botmonitor-last-emailed', getKey(rule)));
}
async saveLastEmailedTime(rule: Rule) {
await this.redis.hset('botmonitor-last-emailed', getKey(rule), new bot.date().toISOString);
}
}

export const alertsDb: AlertsDb = new MariadbAlertsDb();
48 changes: 26 additions & 22 deletions bot-monitor/ChecksDb.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {argv, bot, emailOnError, log} from "../botbase";
import {RawRule, getFromDate} from './index'
import {RawRule, subtractFromNow} from './index'

// Only a small amount of data is stored. Probably not worth using ToolsDB.
import * as sqlite from "sqlite";
Expand Down Expand Up @@ -37,46 +37,50 @@ interface ChecksDb {
*
*/
class RedisChecksDb implements ChecksDb {
connect() {
getRedisInstance().ping();
redis = getRedisInstance();

async connect() {
await this.redis.ping();
}

getKey(rule: RawRule) {
return `${rule.bot}: ${rule.task}`.slice(0, 250);
return `botmonitor-${rule.bot}: ${rule.task}`;
}

update(rule: RawRule, ts: string) {
// getRedisInstance().hmset(this.getKey(rule), {
// rule: hash.sha1(rule),
// ts: ts
// });
getRedisInstance().hset(this.getKey(rule), 'rulehash', hash.sha1(rule));
getRedisInstance().hset(this.getKey(rule), 'ts', ts);

async update(rule: RawRule, ts: string) {
await this.redis.hset(this.getKey(rule) + '-cached',
'rulehash', hash.sha1(rule),
'ts', ts);
}

checkCached(rule: RawRule) {
let cached = getRedisInstance().hgetall(this.getKey(rule) + '-cached');
async checkCached(rule: RawRule) {
if (argv.nocache) {
return false;
}
let cached = await this.redis.hgetall(this.getKey(rule) + '-cached');
return cached.rulehash === hash.sha1(rule) &&
new bot.date(cached.ts).isAfter(getFromDate(rule.duration));
new bot.date(cached.ts).isAfter(subtractFromNow(rule.duration));
}

getLastSeen(rule: RawRule) {
async getLastSeen(rule: RawRule) {
if (argv.nocache) {
return;
}
let lastseen = getRedisInstance().hgetall(this.getKey(rule) + '-seen');
let lastseen = await this.redis.hgetall(this.getKey(rule) + '-seen');
if (lastseen && lastseen.rulehash === hash.sha1(rule)) {
return lastseen;
} // else return undefined
}

updateLastSeen(rule: RawRule, ts: string, notSeen?: boolean) {
getRedisInstance().hset(this.getKey(rule) + '-seen', 'rulehash', hash.sha1(rule));
getRedisInstance().hset(this.getKey(rule) + '-seen', 'ts', ts);
async updateLastSeen(rule: RawRule, ts: string, notSeen?: boolean) {
let props: Record<string, string> = {
rulehash: hash.sha1(rule),
ts: ts,
};
if (notSeen) {
getRedisInstance().hset(this.getKey(rule) + '-seen', 'notseen', '1');
props.notseen = '1';
}
await this.redis.hset(this.getKey(rule) + '-seen', ...Object.entries(props).flat());
}

}
Expand Down Expand Up @@ -130,7 +134,7 @@ class SqliteChecksDb extends SqliteDb implements ChecksDb {
]); // on error, last remains undefined
return last &&
last.rulehash === hash.sha1(rule) && // check that the rule itself hasn't changed
new bot.date(last.ts).isAfter(getFromDate(rule.duration));
new bot.date(last.ts).isAfter(subtractFromNow(rule.duration));
}


Expand Down
4 changes: 2 additions & 2 deletions bot-monitor/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {bot, emailOnError, log, logFullError} from '../botbase';
import {MwnDate} from "../../mwn";
import type {ApiQueryLogEventsParams, ApiQueryUserContribsParams} from "../../mwn/src/api_params";
import type {LogEvent, UserContribution} from "../../mwn/src/api_response_types";
import {Alert, checksDb, getFromDate, parseRule, RawRule, Rule, RuleError, Tabulator} from './index'
import {Alert, checksDb, subtractFromNow, parseRule, RawRule, Rule, RuleError, Tabulator} from './index'

export class Monitor {
name: string
Expand Down Expand Up @@ -163,7 +163,7 @@ export class Monitor {
...apiParams,
...listParams({
start: lastAction.timestamp,
end: getFromDate(this.rule.duration, 4)
end: subtractFromNow(this.rule.duration, 4)
})
})) {
if (checkAction(action)) {
Expand Down
23 changes: 10 additions & 13 deletions bot-monitor/Rule.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {argv, bot, Mwn, path} from "../botbase";
import {MwnTitle, MwnDate} from "../../mwn";
import {getFromDate} from "./index";
import {subtractFromNow} from "./index";
import {readFile} from "../utils";
import {NS_USER_TALK} from "../namespaces";

Expand All @@ -17,10 +17,8 @@ export interface Rule {
duration: string
fromDate: MwnDate
alertPage: MwnTitle
email: string

// alertMode: 'email' | 'talkpage' | 'ping'
// emailUser: string
// header: string
// pingUser: string
}

Expand All @@ -36,15 +34,16 @@ export type BotConfigParam =
| 'min_edits'
| 'duration'
| 'notify'
| 'email'

// | 'alert_mode'
// | 'alert_page'
// | 'email_user'
// | 'header'
// | 'ping_user'

export class RuleError extends Error {}

export function getKey(rule: RawRule | Rule, maxLength = -1) {
return `${rule.bot}: ${rule.task}`.slice(0, maxLength);
}

export async function fetchRules(): Promise<RawRule[]> {
let text = !argv.fake ?
await new bot.page('Wikipedia:Bot activity monitor/Configurations').text() :
Expand All @@ -65,7 +64,7 @@ export async function fetchRules(): Promise<RawRule[]> {

export function parseRule(rule: RawRule): Rule {
rule.duration = rule.duration || '3 days';
let fromDate = getFromDate(rule.duration);
let fromDate = subtractFromNow(rule.duration);

if (!rule.bot) {
throw new RuleError(`No bot account specified!`);
Expand Down Expand Up @@ -93,11 +92,9 @@ export function parseRule(rule: RawRule): Rule {
summaryRegex: (rule.summary && new RegExp('^' + Mwn.util.escapeRegExp(rule.summary) + '$')) ||
(rule.summary_regex && new RegExp('^' + rule.summary_regex + '$')),
minEdits: rule.min_edits ? parseInt(rule.min_edits) : 1,
alertPage
alertPage,
email: rule.email,

// alertMode: rule.alert_mode as 'talkpage' | 'email' | 'ping',
// emailUser: rule.email_user || rule.bot,
// pingUser: rule.ping_user,
// header: rule.header,
};
}
Loading

0 comments on commit 042697f

Please sign in to comment.