A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

497 Zeilen
20 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2012 Ben Kurtovic <ben.kurtovic@verizon.net>
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining a copy
  6. # of this software and associated documentation files (the "Software"), to deal
  7. # in the Software without restriction, including without limitation the rights
  8. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. # copies of the Software, and to permit persons to whom the Software is
  10. # furnished to do so, subject to the following conditions:
  11. #
  12. # The above copyright notice and this permission notice shall be included in
  13. # all copies or substantial portions of the Software.
  14. #
  15. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21. # SOFTWARE.
  22. from datetime import datetime
  23. from os import expanduser
  24. import re
  25. from threading import RLock
  26. from time import sleep, time
  27. import oursql
  28. from earwigbot import exceptions
  29. from earwigbot.tasks import Task
  30. class DRNClerkBot(Task):
  31. """A task to clerk for [[WP:DRN]]."""
  32. name = "drn_clerkbot"
  33. number = 19
  34. # Case status:
  35. STATUS_UNKNOWN = 0
  36. STATUS_NEW = 1
  37. STATUS_OPEN = 2
  38. STATUS_STALE = 3
  39. STATUS_NEEDASSIST = 4
  40. STATUS_REVIEW = 5
  41. STATUS_RESOLVED = 6
  42. STATUS_CLOSED = 7
  43. STATUS_ARCHIVE = 8
  44. ALIASES = {
  45. STATUS_NEW: ("",),
  46. STATUS_OPEN: ("open", "active", "inprogress"),
  47. STATUS_STALE: ("stale",),
  48. STATUS_NEEDASSIST: ("needassist", "relist", "relisted"),
  49. STATUS_REVIEW: ("review",),
  50. STATUS_RESOLVED: ("resolved", "resolve"),
  51. STATUS_CLOSED: ("closed", "close"),
  52. }
  53. def setup(self):
  54. """Hook called immediately after the task is loaded."""
  55. cfg = self.config.tasks.get(self.name, {})
  56. # Set some wiki-related attributes:
  57. self.title = cfg.get("title", "Wikipedia:Dispute resolution noticeboard")
  58. self.talk = cfg.get("talk", "Wikipedia talk:Dispute resolution noticeboard")
  59. self.volunteer_title = cfg.get("volunteers", "Wikipedia:Dispute resolution noticeboard/Volunteering")
  60. self.very_old_title = cfg.get("veryOldTitle", "User talk:Szhang (WMF)")
  61. default_summary = "Updating $3 cases for the [[WP:DRN|dispute resolution noticeboard]]."
  62. self.summary = self.make_summary(cfg.get("summary", default_summary))
  63. # Templates used:
  64. templates = cfg.get("templates", {})
  65. self.tl_status = templates.get("status", "DR case status")
  66. self.tl_notify_party = templates.get("notifyParty", "DRN-notice")
  67. self.tl_notify_stale = templates.get("notifyStale", "DRN stale notice")
  68. self.tl_archive_top = templates.get("archiveTop", "DRN archive top")
  69. self.tl_archive_bottom = templates.get("archiveBottom",
  70. "DRN archive bottom")
  71. # Connection data for our SQL database:
  72. kwargs = cfg.get("sql", {})
  73. kwargs["read_default_file"] = expanduser("~/.my.cnf")
  74. self.conn_data = kwargs
  75. self.db_access_lock = RLock()
  76. def run(self, **kwargs):
  77. """Entry point for a task event."""
  78. if not self.db_access_lock.acquire(False): # Non-blocking
  79. self.logger.info("A job is already ongoing; aborting")
  80. return
  81. action = kwargs.get("action", "all")
  82. try:
  83. start = time()
  84. conn = oursql.connect(**self.conn_data)
  85. site = self.bot.wiki.get_site()
  86. if action in ["all", "update_volunteers"]:
  87. self.update_volunteers(conn, site)
  88. if action in ["all", "clerk"]:
  89. log = u"Starting update to [[{0}]]".format(self.title)
  90. self.logger.info(log)
  91. cases = self.read_database(conn)
  92. page = site.get_page(self.title)
  93. text = page.get()
  94. self.read_page(conn, cases, text)
  95. noticies = self.clerk(conn, cases)
  96. self.save(page, cases, kwargs)
  97. self.send_notices(site, notices)
  98. finally:
  99. self.db_access_lock.release()
  100. def update_volunteers(self, conn, site):
  101. """Updates and stores the list of dispute resolution volunteers."""
  102. log = u"Updating volunteer list from [[{0}]]"
  103. self.logger.info(log.format(self.volunteer_title))
  104. page = site.get_page(self.volunteer_title)
  105. try:
  106. text = page.get()
  107. except exceptions.PageNotFoundError:
  108. text = ""
  109. marker = "<!-- please don't remove this comment (used by EarwigBot) -->"
  110. if marker not in text:
  111. log = u"The marker ({0}) wasn't found in the volunteer list at [[{1}]]!"
  112. self.logger.error(log.format(marker, page.title))
  113. text = text.split(marker)[1]
  114. additions = set()
  115. for line in text.splitlines():
  116. user = re.search("\# \{\{User\|(.*?)\}\}", line)
  117. if user:
  118. additions.add((user.group(1)))
  119. removals = set()
  120. query1 = "SELECT volunteer_username FROM volunteer"
  121. query2 = "DELETE FROM volunteer WHERE volunteer_username = ?"
  122. query3 = "INSERT INTO volunteer VALUES (?)"
  123. with conn.cursor() as cursor:
  124. cursor.execute(query1)
  125. for row in cursor:
  126. if row in additions:
  127. additions.remove(row)
  128. else:
  129. removals.add(row)
  130. if removals:
  131. cursor.executemany(query2, removals)
  132. if additions:
  133. cursor.executemany(query3, additions)
  134. def read_database(self, conn):
  135. """Return a list of _Cases from the database."""
  136. cases = []
  137. with conn.cursor() as cursor:
  138. cursor.execute("SELECT * FROM case")
  139. for row in cursor:
  140. case = _Case(*row)
  141. cases.append(case)
  142. return cases
  143. def read_page(self, conn, cases, text):
  144. """Read the noticeboard content and update the list of _Cases."""
  145. tl_status_esc = re.escape(self.tl_status)
  146. split = re.split("(^==\s*[^=]+?\s*==$)", text, flags=re.M|re.U)
  147. for i in xrange(len(split)):
  148. if i + 1 == len(split):
  149. break
  150. if not split[i].startswith("=="):
  151. continue
  152. title = split[i][2:-2].strip()
  153. body = old = split[i + 1]
  154. if not re.search("\s*\{\{" + tl_status_esc, body, re.U):
  155. continue
  156. status = self.read_status(body)
  157. re_id = "<!-- Bot Case ID \(please don't modify\): (.*?) -->"
  158. try:
  159. id_ = re.search(re_id, body).group(1)
  160. case = [case for case in cases if case.id == id_][0]
  161. except (AttributeError, IndexError):
  162. id_ = self.select_next_id(conn, "case_id", "case")
  163. re_id2 = "(\{\{" + tl_status_esc
  164. re_id2 += "(.*?)\}\})(<!-- Bot Case ID \(please don't modify\): .*? -->)?"
  165. repl = ur"\1 <!-- Bot Case ID (please don't modify): {0} -->"
  166. body = re.sub(re_id2, repl.format(id_), body)
  167. case = _Case(id_, title, status, self.STATUS_UNKNOWN, time(),
  168. 0, False, False, 0, new=True)
  169. cases.append(case)
  170. else:
  171. case.status = status
  172. if case.title != title:
  173. self.update_case_title(conn, id_, title)
  174. case.title = title
  175. case.body, case.old = body, old
  176. def select_next_id(self, conn, column, table):
  177. """Return the next incremental ID for a case."""
  178. query = "SELECT MAX(?) FROM ?"
  179. with conn.cursor() as cursor:
  180. cursor.execute(query, (column, table))
  181. current = cursor.fetchone()[0]
  182. if current:
  183. return current + 1
  184. return 1
  185. def read_status(self, body):
  186. """Parse the current status from a case body."""
  187. templ = re.escape(self.tl_status)
  188. status = re.search("\{\{" + templ + "\|?(.*?)\}\}", body, re.S|re.U)
  189. if not status:
  190. return self.STATUS_UNKNOWN
  191. for option, names in self.ALIASES.iteritems():
  192. if status.group(1).lower() in names:
  193. return option
  194. return self.STATUS_UNKNOWN
  195. def update_case_title(self, conn, id_, title):
  196. """Update a case title in the database."""
  197. query = "UPDATE case SET case_title = ? WHERE case_id = ?"
  198. with conn.cursor() as cursor:
  199. cursor.execute(query, (title, id_))
  200. def clerk(self, conn, cases):
  201. """Actually go through cases and modify those to be updated."""
  202. query = "SELECT volunteer_username FROM volunteer"
  203. with conn.cursor() as cursor:
  204. cursor.execute(query)
  205. volunteers = [name for (name,) in cursor.fetchall()]
  206. notices = []
  207. for case in cases:
  208. notices += self.clerk_case(conn, case, volunteers)
  209. return notices
  210. def clerk_case(self, conn, case, volunteers):
  211. """Clerk a particular case and return a list of any notices to send."""
  212. notices = []
  213. signatures = self.read_signatures(case.body)
  214. storedsigs = self.get_signatures_from_db(conn, case)
  215. newsigs = set(signatures) - set(storedsigs)
  216. if any([editor in volunteers for (editor, timestamp) in newsigs]):
  217. case.last_volunteer_size = len(case.body)
  218. if case.status == self.STATUS_NEW:
  219. notices = self.clerk_new_case(case, volunteers, signatures)
  220. elif case.status == self.STATUS_OPEN:
  221. notices = self.clerk_open_case(case, signatures)
  222. elif case.status == self.STATUS_NEEDASSIST:
  223. notices = self.clerk_needassist_case(case, volunteers, newsigs)
  224. elif case.status == self.STATUS_STALE:
  225. notices = self.clerk_stale_case(case, newsigs)
  226. elif case.status == self.STATUS_REVIEW:
  227. notices = self.clerk_review_case(case)
  228. elif case.status in [self.STATUS_RESOLVED, self.STATUS_CLOSED]:
  229. self.clerk_closed_case(conn, signatures)
  230. else:
  231. log = u"Unsure of how to deal with case {0} (title: {1})"
  232. self.logger.error(log.format(case.id, case.title))
  233. return notices
  234. self.save_case_updates(conn, case, signatures, storedsigs)
  235. return notices
  236. def clerk_new_case(self, case, volunteers, signatures):
  237. notices = self.notify_parties(case)
  238. if any([editor in volunteers for (editor, timestamp) in signatures]):
  239. if case.last_action != self.STATUS_OPEN:
  240. case.status = self.STATUS_OPEN
  241. return notices
  242. def clerk_open_case(self, case, signatures):
  243. self.check_for_review(case):
  244. if len(case.body) - case.last_volunteer_size > 15000:
  245. if case.last_action != self.STATUS_NEEDASSIST:
  246. case.status = self.STATUS_NEEDASSIST
  247. timestamps = [timestamp for (editor, timestamp) in signatures]
  248. if time() - max(timestamps) > 60 * 60 * 24 * 2:
  249. if case.last_action != self.STATUS_STALE:
  250. case.status = self.STATUS_STALE
  251. return []
  252. def clerk_needassist_case(self, case, volunteers, newsigs):
  253. self.check_for_review(case):
  254. if any([editor in volunteers for (editor, timestamp) in newsigs]):
  255. if case.last_action != self.STATUS_OPEN:
  256. case.status = self.STATUS_OPEN
  257. return []
  258. def clerk_stale_case(self, case, newsigs):
  259. self.check_for_review(case):
  260. if newsigs:
  261. if case.last_action != self.STATUS_OPEN:
  262. case.status = self.STATUS_OPEN
  263. return []
  264. def clerk_review_case(self, case):
  265. if time() - case.file_time > 60 * 60 * 24 * 7:
  266. if not case.very_old_notified:
  267. template = "{{subst:" + self.tl_notify_stale
  268. template += case.title.replace("|", "&#124;") + "}}"
  269. notice = _Notice(self.very_old_title, template)
  270. case.very_old_notified = True
  271. return [notice]
  272. return []
  273. def clerk_closed_case(self, case, signatures):
  274. if not case.close_time:
  275. case.close_time = time()
  276. timestamps = [timestamp for (editor, timestamp) in signatures]
  277. closed_long_ago = time() - case.close_time > 60 * 60 * 24
  278. modified_long_ago = time() - max(timestamps) > 60 * 60 * 24
  279. if closed_long_ago and modified_long_ago:
  280. case.status = self.STATUS_ARCHIVE
  281. case.body = "{{" + self.tl_archive_top + "}}\n" + case.body
  282. case.body += "\n{{" + self.tl_archive_bottom + "}}"
  283. reg = "<!-- \[\[User:DoNotArchiveUntil\]\] .*? -->(<!-- .*? -->)?"
  284. case.body = re.sub(reg, "", case.body)
  285. def check_for_review(self, case):
  286. if time() - case.file_time > 60 * 60 * 24 * 4:
  287. if case.last_action != self.STATUS_REVIEW:
  288. case.status = self.STATUS_REVIEW
  289. def read_signatures(self, text):
  290. regex = r"\[\[(?:User(?:\stalk)?\:|Special\:Contributions\/)(.*?)(?:\||\]\]).{,256}?(\d{2}:\d{2},\s\d{2}\s\w+\s\d{4}\s\(UTC\))"
  291. matches = re.findall(regex, text, re.U)
  292. signatures = []
  293. for userlink, stamp in matches:
  294. username = userlink.split("/", 1)[0].replace("_", " ")
  295. timestamp = datetime.strptime("%H:%M, %d %B %Y (UTC)", stamp)
  296. signatures.append((username, timestamp))
  297. return signatures
  298. def get_signatures_from_db(self, conn, case):
  299. query = "SELECT signature_username, signature_timestamp FROM signature WHERE signature_case = ?"
  300. with conn.cursor() as cursor:
  301. cursor.execute(query, (case.id,))
  302. return cursor.fetchall()
  303. def notify_parties(self, case):
  304. if case.parties_notified:
  305. return []
  306. notices = []
  307. template = "{{subst:" + self.tl_notify_party
  308. template += "|thread=" + case.title + "}} ~~~~"
  309. too_late = "<!--Template:DRN-notice-->"
  310. re_parties = "<span.*?>'''Users involved'''</span>(.*?)<span.*?>"
  311. text = re.search(re_parties, case, re.S|re.U)
  312. for line in text.group(1).splitlines():
  313. user = re.search("\# \{\{User\|(.*?)\}\}", line)
  314. if user:
  315. party = user.group(1).strip()
  316. notice = _Notice("User talk:" + party, template, too_late)
  317. case.parties_notified = True
  318. return notices
  319. def save_case_updates(self, conn, case, signatures, storedsigs):
  320. if case.status != case.original_status:
  321. case.last_action = case.status
  322. new = self.ALIASES[case.status][0]
  323. tl_status_esc = re.escape(self.tl_status)
  324. search = "\{\{" + tl_status_esc + "(\|?.*?)\}\}"
  325. repl = "{{" + self.tl_status + "|" + new + "}}"
  326. case.body = re.sub(search, repl, case.body)
  327. if case.new:
  328. self.save_new_case(conn, case)
  329. else:
  330. self.save_existing_case(conn, case)
  331. with conn.cursor() as cursor:
  332. query1 = "DELETE FROM signature WHERE signature_case = ? AND signature_username = ? AND signature_timestamp = ?"
  333. query2 = "INSERT INTO signature VALUES (?, ?, ?, ?)"
  334. removals = set(storedsigs) - set(signatures)
  335. additions = set(signatures) - set(storedsigs)
  336. if removals:
  337. args = [(case.id, name, stamp) for (name, stamp) in removals]
  338. cursor.execute(query1, args)
  339. if additions:
  340. nextid = self.select_next_id(conn, "signature_id", "signature")
  341. args = []
  342. for name, stamp in additions:
  343. args.append((nextid, case.id, name, stamp))
  344. nextid += 1
  345. cursor.execute(query2, args)
  346. def save_new_case(self, conn, case):
  347. args = (case.id, case.title, case.status, case.last_action,
  348. case.file_time, case.close_time, case.parties_notified,
  349. case.very_old_notified)
  350. with conn.cursor() as cursor:
  351. query = "INSERT INTO case VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
  352. cursor.execute(query, args)
  353. def save_existing_case(self, conn, case):
  354. with conn.cursor(oursql.DictCursor) as cursor:
  355. query = "SELECT * FROM case WHERE case_id = ?"
  356. cursor.execute(query, (case.id,))
  357. stored = cursor.fetchone()
  358. with conn.cursor() as cursor:
  359. changes, args = [], []
  360. fields_to_check = [
  361. ("case_status", case.status),
  362. ("case_last_action", case.last_action),
  363. ("case_close_time", case.close_time),
  364. ("case_parties_notified", case.parties_notified),
  365. ("case_very_old_notified", case.very_old_notified),
  366. ("case_last_volunteer_size", case.last_volunteer_size)
  367. ]
  368. for column, data in fields_to_check:
  369. if data != stored[column]:
  370. changes.append(column + " = ?")
  371. args.append(data)
  372. if changes:
  373. changes = ", ".join(changes)
  374. args.append(case.id)
  375. query = "UPDATE case SET {0} WHERE case_id = ?".format(changes)
  376. cursor.execute(query, args)
  377. def save(self, page, cases, kwargs):
  378. """Save any changes to the noticeboard."""
  379. newtext = text = page.get()
  380. counter = 0
  381. for case in cases:
  382. if case.old != case.body:
  383. newtext = newtext.replace(case.old, case.body)
  384. counter += 1
  385. if newtext == text:
  386. self.logger.info(u"Nothing to edit on [[{0}]]".format(page.title))
  387. return
  388. worktime = time() - start
  389. if worktime < 60:
  390. sleep(60 - worktime)
  391. page.reload()
  392. if page.get() != text:
  393. log = "Someone has edited the page while we were working; restarting"
  394. self.logger.warn(log)
  395. return self.run(**kwargs)
  396. summary = self.summary.replace("$3", str(counter))
  397. page.edit(text, summary, minor=True, bot=True)
  398. self.logger.info(u"Saved page [[{0}]]".format(page.title))
  399. def send_notices(self, site, notices):
  400. """Send out any templated notices to users or pages."""
  401. if not notices:
  402. self.logger.info("No notices to send; finishing")
  403. return
  404. for notice in notices:
  405. target, template = notice.target, notice.template
  406. log = u"Notifying [[{0}]] with {1}".format(target, template)
  407. self.logger.info(log)
  408. page = site.get_page(target)
  409. try:
  410. text = page.get()
  411. except exceptions.PageNotFoundError:
  412. text = ""
  413. if notice.too_late and notice.too_late in text:
  414. log = u"Skipping [[{0}]]; was already notified".format(target)
  415. self.logger.info(log)
  416. text += ("\n" if text else "") + template
  417. try:
  418. page.edit(text, summary, minor=False, bot=True)
  419. except exceptions.EditError as error:
  420. name, msg = type(error).name, error.message
  421. log = u"Couldn't leave notice on {0} because of {1}: {2}"
  422. self.logger.error(log.format(page.title, name, msg))
  423. self.logger.info("Done sending notices")
  424. class _Case(object):
  425. """A object representing a dispute resolution case."""
  426. def __init__(self, id_, title, status, last_action, file_time, close_time,
  427. parties_notified, very_old_notified, last_volunteer_size,
  428. new=False):
  429. self.id = id_
  430. self.title = title
  431. self.status = status
  432. self.last_action = last_action
  433. self.file_time = file_time
  434. self.close_time = close_time
  435. self.parties_notified = parties_notified
  436. self.very_old_notified = very_old_notified
  437. self.last_volunteer_size = last_volunteer_size
  438. self.new = new
  439. self.original_status = status
  440. self.body = None
  441. self.old = None
  442. class _Notice(object):
  443. """An object representing a notice to be sent to a user or a page."""
  444. def __init__(self, target, template, too_late=None):
  445. self.target = target
  446. self.template = template
  447. self.too_late = too_late