enwnbot2

Converts MediaWiki [[links]] and {{templates}} to links, informs important events from wiki, handles announces review queue, and under review, and handles when they last saw a given user.
git clone http://git.hanabi.in/repos/enwnbot2.git
Log | Files | Refs | README | LICENSE

commit a07755e32287ec6fa89dfeb0c25ca6933481a388
Author: Agastya Chandrakant <acagastya@outlook.com>
Date:   Wed, 30 Sep 2020 16:10:11 +0530

initial commit

Diffstat:
A.gitignore | 1+
ALICENSE | 29+++++++++++++++++++++++++++++
Aconfig.js | 12++++++++++++
Aindex.js | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackage.json | 23+++++++++++++++++++++++
Autils.js | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ayarn.lock | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 434 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/LICENSE b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2020, Agastya +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/config.js b/config.js @@ -0,0 +1,12 @@ +module.exports = { + channels: ["#wikinews", "#wikinews-en", "#wikinewsie-group"], + ircBotName: "enwnbot", + ircServer: "irc.freenode.net", + RCAPI: "https://stream.wikimedia.org/v2/stream/recentchange", + RQAPI: + "https://en.wikinews.org/w/api.php?action=query&list=categorymembers&cmtitle=Category:Review&format=json&cmsort=timestamp&cmprop=timestamp|ids|title", + URAPI: + "https://en.wikinews.org/w/api.php?action=query&list=categorymembers&cmtitle=Category:Under%20review&format=json&cmsort=timestamp&cmprop=title|timestamp", + URL: "https://en.wikinews.org/w/index.php?title=", + wiki: "enwikinews" +}; diff --git a/index.js b/index.js @@ -0,0 +1,144 @@ +const ES = require("eventsource"); + +const irc = require("irc"); + +const moment = require("moment-timezone"); + +const { + channels, + ircBotName, + ircServer, + RCAPI, + RQAPI, + URAPI +} = require("./config"); + +const { + fetchData, + fullUrl, + getFullLink, + getFullTemplate, + linkRegex, + streamError, + streamMessage, + templateRegex, + thanksRegex +} = require("./utils"); + +const ircClient = new irc.Client(ircServer, ircBotName, { channels }); + +console.log("Connecting to the event stream..."); + +const eventSource = new ES(RCAPI); + +eventSource.onopen = function(event) { + console.log("--- Opened connection."); +}; + +eventSource.onerror = streamError; + +eventSource.onmessage = function(event) { + let msg = streamMessage(event); + if (msg) channels.forEach(channel => ircClient.say(channel, msg)); +}; + +ircClient.addListener("error", function(message) { + console.log("error: ", message); +}); + +ircClient.addListener("pm", function(sender, msg) { + if(msg == "KILL" && ['pizero', 'pizero|afk', 'acagastya'].indexOf(sender) >= 0) process.abort(); + ircClient.say(sender, "I am a bot."); +}); + +ircClient.addListener("message", groupChat); + +function groupChat(sender, channel, msg) { + if (thanksRegex.test(msg)) + ircClient.say(channel, `You are welcome, ${sender}.`); + if (msg.includes(`${ircBotName} !RQ`)) announceRQ(sender, channel); + if (msg.includes(`${ircBotName} !UR`)) announceUR(sender, channel); + + const links = msg.match(linkRegex); + const templates = msg.match(templateRegex); + + if (!msg.endsWith("--ignore") && links) { + const nonEmptyLinks = links.filter(el => el.length > 4); + const fullLinks = nonEmptyLinks.map(getFullLink); + if (fullLinks.length) sayUrls(false, fullLinks, channel); + } + + if (!msg.endsWith("--ignore") && templates) { + const nonEmptyTemplates = templates.filter(el => el.length > 4); + const fullLinks = nonEmptyTemplates.map(getFullTemplate); + if (fullLinks.length) sayUrls(false, fullLinks, channel); + } +} + +async function announceRQ(sender, channel) { + const data = await fetchData(RQAPI); + + if (data.error) + ircClient.say( + channel, + `Error occurred, ${sender}. Try this instead: "[[CAT:REV]]"` + ); + else { + const { list } = data; + if (!list.length) + ircClient.say(channel, `Review queue is empty, ${sender}.`); + else { + ircClient.say( + channel, + `${list.length} articles to review, ${sender}. They are:` + ); + const titles = list.map(({ title }) => title); + const times = list.map(({ timestamp }) => moment().to(moment(timestamp))); + const urls = titles.map(fullUrl); + sayUrls(true, urls, channel, titles, times); + } + } +} + +async function announceUR(sender, channel) { + const data = await fetchData(URAPI); + + if (data.error) + ircClient.say( + channel, + `Error occurred, ${sender}. Try this instead: "[[CAT:Under Review]]"` + ); + else { + const { list } = data; + if (!list.length) + ircClient.say(channel, `No articles are under review, ${sender}.`); + else { + client.say( + channel, + `${list.length} articles are under review, ${sender}. They are:` + ); + const titles = list.map(({ title }) => title); + const times = list.map(({ timestamp }) => moment().to(moment(timestamp))); + const urls = titles.map(fullUrl); + sayUrls(true, urls, channel, titles, times); + } + } +} + +function sayUrls( + review = false, + urlList, + channel, + titles = [], + times = [], + pending = [] +) { + urlList.forEach((url, idx) => { + let msg = url; + if (review) msg += " submitted for review"; + if (times.length) msg += ` *${times[idx]}*`; + if (titles.length) msg += ` -- ${titles[idx]}`; + if (pending[idx]) msg += " *under review*"; + ircClient.say(channel, msg); + }); +} diff --git a/package.json b/package.json @@ -0,0 +1,23 @@ +{ + "name": "enwnbot", + "version": "0.0.1", + "description": "Does what gpy does", + "main": "index.js", + "repository": "https://github.com/acagasyta/enwnbot2", + "author": { + "name": "Agastya", + "email": "me@hanabi.in", + "url": "https://hanabi.in" + }, + "license": "BSD-3-Clause", + "private": true, + "dependencies": { + "eventsource": "^1.0.7", + "irc": "^0.5.2", + "moment-timezone": "^0.5.31", + "node-fetch": "^2.6.1" + }, + "scripts": { + "start": "node index.js" + } +} diff --git a/utils.js b/utils.js @@ -0,0 +1,139 @@ +const fetch = require("node-fetch"); + +const { ircBotName, URL, wiki } = require("./config"); + +async function fetchData(URI) { + const res = {}; + try { + const data = await fetch(URI); + const parsed = await data.json(); + res.list = parsed.query.categorymembers; + } catch (error) { + res.error = true; + console.warn("Error in fetchData:", error); + } + return res; +} + +function fullUrl(title = "") { + const fixedTitle = title.replace(/\?/g, "%3F"); // fix the title for '?' + let [main, anchor] = fixedTitle.split("#"); + main = main.replace(/ /g, "%20"); + if (anchor) anchor = anchor.replace(/ /g, "_"); + else main = main.replace(/%20/g, "_"); + let final = main; + if (anchor) final += `%23${anchor}`; + return `${URL}${final}`; +} + +function getCategory(title = "") { + return title.replace(/^Category:/, ""); +} + +function getFullLink(link) { + const len = link.length; + const trimmed = link.substr(2, len - 4); + const finalUrl = fullUrl(trimmed); + return finalUrl; +} + +function getFullTemplate(template) { + const len = template.length; + const word = template + .substr(2, len - 4) + .split("|")[0] + .replace(/ /g, "%20") + .replace(/\?/g, "%3F"); + return `${URL}Template:${word}`; +} + +const linkRegex = /\[{2}(.*?)\]{2}/g; + +function streamError(event) { + const knownError = { type: "error" }; + const knownErrStr = JSON.stringify(knownError); + const eventStr = JSON.stringify(event); + if (eventStr != knownErrStr) { + ircClient.say("acagastya", eventStr + "\n --- error"); + console.error("--- Encountered error", event); + } +} + +function streamMessage(event) { + let msg = ""; + const change = JSON.parse(event.data); + const { comment, title, type, user } = change; + if (change.wiki == wiki && type == "categirize") { + const category = getCategory(title); + const pageRegex = /\[\[:(.*)\]\]/; + const page = comment.match(pageRegex)[1]; + switch (category) { + case "Editing": { + if (comment.includes("added")) + msg = `Attention! ${user} is now {{editing}} [[${page}]].`; + else msg = `[[${page}]] is no longer under {{editing}}.`; + break; + } + case "Developing": { + if (comment.includes("added")) + msg = `${user} may be working on [[${page}]].`; + else msg = `${user} has removed [[${page}]] from {{develop}}.`; + break; + } + case "Review": { + if (comment.includes("added")) + msg = `${user} has submitted [[${page}]] for review.`; + else msg = `${user} has removed [[${page}]] from review.`; + break; + } + case "Under review": { + if (comment.includes("added")) + msg = `${user} is reviewing [[${page}]].`; + else msg = `${user} is no longer reviewing [[${page}]].`; + break; + } + case "Peer reviewed/Not ready": { + const failedArticleRegex = /^\[\[:Talk:(.*)\]\]/; + const failedArticle = comment.match(failedArticleRegex)[1]; + msg = `${user} failed the ${failedArticle} article.`; + break; + } + case "Abandoned": { + if (comment.includes("added")) + msg = `${user} has marked [[${page}]] as abandoned.`; + break; + } + case "Published": { + if (comment.includes("added")) + msg = `${user} just published [[${page}]].`; + break; + } + case "Archived": { + if (comment.includes("added")) + msg = `${user} has archived [[${page}]].`; + else msg = `${user} has unarchived [[${page}]].`; + break; + } + } + return msg; + } +} + +const templateRegex = /\{{2}(.*?)\}{2}/g; + +const thanksRegex = new RegExp( + `(thanks?|thank you|thankyou),? ${ircBotName}`, + "i" +); + +module.exports = { + fetchData, + fullUrl, + getFullLink, + getFullTemplate, + linkRegex, + streamError, + streamMessage, + templateRegex, + thanksRegex +}; diff --git a/yarn.lock b/yarn.lock @@ -0,0 +1,86 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +eventsource@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.0.7.tgz#8fbc72c93fcd34088090bc0a4e64f4b5cee6d8d0" + integrity sha512-4Ln17+vVT0k8aWq+t/bF5arcS3EpT9gYtW66EPacdj/mAFevznsnyoHLPy2BA8gbIQeIHoPsvwmfBftfcG//BQ== + dependencies: + original "^1.0.0" + +iconv@~2.2.1: + version "2.2.3" + resolved "https://registry.yarnpkg.com/iconv/-/iconv-2.2.3.tgz#e084d60eeb7d73da7f0a9c096e4c8abe090bfaed" + integrity sha1-4ITWDut9c9p/CpwJbkyKvgkL+u0= + dependencies: + nan "^2.3.5" + +irc-colors@^1.1.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/irc-colors/-/irc-colors-1.5.0.tgz#08834c01ead88b0fd88386a5f2af8f2b0bb963fb" + integrity sha512-HtszKchBQTcqw1DC09uD7i7vvMayHGM1OCo6AHt5pkgZEyo99ClhHTMJdf+Ezc9ovuNNxcH89QfyclGthjZJOw== + +irc@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/irc/-/irc-0.5.2.tgz#3714f4768365a96d0b2f776bc91166beb2464bbc" + integrity sha1-NxT0doNlqW0LL3dryRFmvrJGS7w= + dependencies: + irc-colors "^1.1.0" + optionalDependencies: + iconv "~2.2.1" + node-icu-charset-detector "~0.2.0" + +moment-timezone@^0.5.31: + version "0.5.31" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.31.tgz#9c40d8c5026f0c7ab46eda3d63e49c155148de05" + integrity sha512-+GgHNg8xRhMXfEbv81iDtrVeTcWt0kWmTEY1XQK14dICTXnWJnT0dxdlPspwqF3keKMVPXwayEsk1DI0AA/jdA== + dependencies: + moment ">= 2.9.0" + +"moment@>= 2.9.0": + version "2.29.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.0.tgz#fcbef955844d91deb55438613ddcec56e86a3425" + integrity sha512-z6IJ5HXYiuxvFTI6eiQ9dm77uE0gyy1yXNApVHqTcnIKfY9tIwEjlzsZ6u1LQXvVgKeTnv9Xm7NDvJ7lso3MtA== + +nan@^2.3.3, nan@^2.3.5: + version "2.14.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" + integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== + +node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + +node-icu-charset-detector@~0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/node-icu-charset-detector/-/node-icu-charset-detector-0.2.0.tgz#c2320da374ddcb671fc54cb4a0e041e156ffd639" + integrity sha1-wjINo3Tdy2cfxUy0oOBB4Vb/1jk= + dependencies: + nan "^2.3.3" + +original@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" + integrity sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg== + dependencies: + url-parse "^1.4.3" + +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= + +url-parse@^1.4.3: + version "1.4.7" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" + integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0"