A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

320 lines
13 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
  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 path
  24. import re
  25. import sqlite3 as sqlite
  26. from threading import Lock
  27. from earwigbot.commands import Command
  28. class Notes(Command):
  29. """A mini IRC-based wiki for storing notes, tips, and reminders."""
  30. name = "notes"
  31. commands = ["notes", "note", "about"]
  32. version = 2
  33. def setup(self):
  34. self._dbfile = path.join(self.config.root_dir, "notes.db")
  35. self._db_access_lock = Lock()
  36. def process(self, data):
  37. commands = {
  38. "help": self.do_help,
  39. "list": self.do_list,
  40. "read": self.do_read,
  41. "edit": self.do_edit,
  42. "info": self.do_info,
  43. "rename": self.do_rename,
  44. "delete": self.do_delete,
  45. }
  46. if not data.args:
  47. msg = "\x0302The Earwig Mini-Wiki\x0F: running v{0}. Subcommands are: {1}. You can get help on any with '!{2} help subcommand'."
  48. cmnds = ", ".join((commands))
  49. self.reply(data, msg.format(self.version, cmnds, data.command))
  50. return
  51. command = data.args[0].lower()
  52. if command in commands:
  53. commands[command](data)
  54. else:
  55. msg = "Unknown subcommand: \x0303{0}\x0F.".format(command)
  56. self.reply(data, msg)
  57. def do_help(self, data):
  58. """Get help on a subcommand."""
  59. info = {
  60. "help": "Get help on other subcommands.",
  61. "list": "List existing entries.",
  62. "read": "Read an existing entry ('!notes read [name]').",
  63. "edit": """Modify or create a new entry ('!notes edit name
  64. [entry content]...'). If modifying, you must be the
  65. entry author or a bot admin.""",
  66. "info": """Get information on an existing entry ('!notes info
  67. [name]').""",
  68. "rename": """Rename an existing entry ('!notes rename [old_name]
  69. [new_name]'). You must be the entry author or a bot
  70. admin.""",
  71. "delete": """Delete an existing entry ('!notes delete [name]'). You
  72. must be the entry author or a bot admin.""",
  73. }
  74. try:
  75. command = data.args[1]
  76. except IndexError:
  77. self.reply(data, "Please specify a subcommand to get help on.")
  78. return
  79. try:
  80. help_ = re.sub(r"\s\s+", " ", info[command].replace("\n", ""))
  81. self.reply(data, "\x0303{0}\x0F: ".format(command) + help_)
  82. except KeyError:
  83. msg = "Unknown subcommand: \x0303{0}\x0F.".format(command)
  84. self.reply(data, msg)
  85. def do_list(self, data):
  86. """Show a list of entries in the notes database."""
  87. query = "SELECT entry_title FROM entries"
  88. with sqlite.connect(self._dbfile) as conn, self._db_access_lock:
  89. try:
  90. entries = conn.execute(query).fetchall()
  91. except sqlite.OperationalError:
  92. entries = []
  93. if entries:
  94. entries = [entry[0] for entry in entries]
  95. self.reply(data, "Entries: {0}".format(", ".join(entries)))
  96. else:
  97. self.reply(data, "No entries in the database.")
  98. def do_read(self, data):
  99. """Read an entry from the notes database."""
  100. query = """SELECT entry_title, rev_content FROM entries
  101. INNER JOIN revisions ON entry_revision = rev_id
  102. WHERE entry_slug = ?"""
  103. try:
  104. slug = self.slugify(data.args[1])
  105. except IndexError:
  106. self.reply(data, "Please specify an entry to read from.")
  107. return
  108. with sqlite.connect(self._dbfile) as conn, self._db_access_lock:
  109. try:
  110. title, content = conn.execute(query, (slug,)).fetchone()
  111. except (sqlite.OperationalError, TypeError):
  112. title, content = slug, None
  113. if content:
  114. self.reply(data, "\x0302{0}\x0F: {1}".format(title, content))
  115. else:
  116. self.reply(data, "Entry \x0302{0}\x0F not found.".format(title))
  117. def do_edit(self, data):
  118. """Edit an entry in the notes database."""
  119. query1 = """SELECT entry_id, entry_title, user_host FROM entries
  120. INNER JOIN revisions ON entry_revision = rev_id
  121. INNER JOIN users ON rev_user = user_id
  122. WHERE entry_slug = ?"""
  123. query2 = "INSERT INTO revisions VALUES (?, ?, ?, ?, ?)"
  124. query3 = "INSERT INTO entries VALUES (?, ?, ?, ?)"
  125. query4 = "UPDATE entries SET entry_revision = ? WHERE entry_id = ?"
  126. try:
  127. slug = self.slugify(data.args[1])
  128. except IndexError:
  129. self.reply(data, "Please specify an entry to edit.")
  130. return
  131. content = " ".join(data.args[2:]).strip()
  132. if not content:
  133. self.reply(data, "Please give some content to put in the entry.")
  134. return
  135. with sqlite.connect(self._dbfile) as conn, self._db_access_lock:
  136. create = True
  137. try:
  138. id_, title, author = conn.execute(query1, (slug,)).fetchone()
  139. create = False
  140. except sqlite.OperationalError:
  141. id_, title, author = 1, data.args[1], data.host
  142. self.create_db(conn)
  143. except TypeError:
  144. id_ = self.get_next_entry(conn)
  145. title, author = data.args[1], data.host
  146. permdb = self.config.irc["permissions"]
  147. if author != data.host and not permdb.is_admin(data):
  148. msg = "You must be an author or a bot admin to edit this entry."
  149. self.reply(data, msg)
  150. return
  151. revid = self.get_next_revision(conn)
  152. userid = self.get_user(conn, data.host)
  153. now = datetime.utcnow().strftime("%b %d, %Y %H:%M:%S")
  154. conn.execute(query2, (revid, id_, userid, now, content))
  155. if create:
  156. conn.execute(query3, (id_, slug, title, revid))
  157. else:
  158. conn.execute(query4, (revid, id_))
  159. self.reply(data, "Entry \x0302{0}\x0F updated.".format(title))
  160. def do_info(self, data):
  161. """Get info on an entry in the notes database."""
  162. query = """SELECT entry_title, rev_timestamp, user_host FROM entries
  163. INNER JOIN revisions ON entry_id = rev_entry
  164. INNER JOIN users ON rev_user = user_id
  165. WHERE entry_slug = ?"""
  166. try:
  167. slug = self.slugify(data.args[1])
  168. except IndexError:
  169. self.reply(data, "Please specify an entry to get info on.")
  170. return
  171. with sqlite.connect(self._dbfile) as conn, self._db_access_lock:
  172. try:
  173. info = conn.execute(query, (slug,)).fetchall()
  174. except sqlite.OperationalError:
  175. info = []
  176. if info:
  177. title = info[0][0]
  178. times = [datum[1] for datum in info]
  179. earliest = min(times)
  180. msg = "\x0302{0}\x0F: {1} edits since {2}"
  181. msg = msg.format(title, len(info), earliest)
  182. if len(times) > 1:
  183. latest = max(times)
  184. msg += "; last edit on {0}".format(latest)
  185. names = [datum[2] for datum in info]
  186. msg += "; authors: {0}.".format(", ".join(list(set(names))))
  187. self.reply(data, msg)
  188. else:
  189. title = data.args[1]
  190. self.reply(data, "Entry \x0302{0}\x0F not found.".format(title))
  191. def do_rename(self, data):
  192. """Rename an entry in the notes database."""
  193. query1 = """SELECT entry_id, user_host FROM entries
  194. INNER JOIN revisions ON entry_revision = rev_id
  195. INNER JOIN users ON rev_user = user_id
  196. WHERE entry_slug = ?"""
  197. query2 = """UPDATE entries SET entry_slug = ?, entry_title = ?
  198. WHERE entry_id = ?"""
  199. try:
  200. slug = self.slugify(data.args[1])
  201. except IndexError:
  202. self.reply(data, "Please specify an entry to rename.")
  203. return
  204. try:
  205. newtitle = data.args[2]
  206. except IndexError:
  207. self.reply(data, "Please specify a new name for the entry.")
  208. return
  209. if newtitle == data.args[1]:
  210. self.reply(data, "The old and new names are identical.")
  211. return
  212. with sqlite.connect(self._dbfile) as conn, self._db_access_lock:
  213. try:
  214. id_, author = conn.execute(query1, (slug,)).fetchone()
  215. except (sqlite.OperationalError, TypeError):
  216. msg = "Entry \x0302{0}\x0F not found.".format(data.args[1])
  217. self.reply(data, msg)
  218. return
  219. permdb = self.config.irc["permissions"]
  220. if author != data.host and not permdb.is_admin(data):
  221. msg = "You must be an author or a bot admin to rename this entry."
  222. self.reply(data, msg)
  223. return
  224. conn.execute(query2, (self.slugify(newtitle), newtitle, id_))
  225. msg = "Entry \x0302{0}\x0F renamed to \x0302{1}\x0F."
  226. self.reply(data, msg.format(data.args[1], newtitle))
  227. def do_delete(self, data):
  228. """Delete an entry from the notes database."""
  229. query1 = """SELECT entry_id, user_host FROM entries
  230. INNER JOIN revisions ON entry_revision = rev_id
  231. INNER JOIN users ON rev_user = user_id
  232. WHERE entry_slug = ?"""
  233. query2 = "DELETE FROM entries WHERE entry_id = ?"
  234. query3 = "DELETE FROM revisions WHERE rev_entry = ?"
  235. try:
  236. slug = self.slugify(data.args[1])
  237. except IndexError:
  238. self.reply(data, "Please specify an entry to delete.")
  239. return
  240. with sqlite.connect(self._dbfile) as conn, self._db_access_lock:
  241. try:
  242. id_, author = conn.execute(query1, (slug,)).fetchone()
  243. except (sqlite.OperationalError, TypeError):
  244. msg = "Entry \x0302{0}\x0F not found.".format(data.args[1])
  245. self.reply(data, msg)
  246. return
  247. permdb = self.config.irc["permissions"]
  248. if author != data.host and not permdb.is_admin(data):
  249. msg = "You must be an author or a bot admin to delete this entry."
  250. self.reply(data, msg)
  251. return
  252. conn.execute(query2, (id_,))
  253. conn.execute(query3, (id_,))
  254. self.reply(data, "Entry \x0302{0}\x0F deleted.".format(data.args[1]))
  255. def slugify(self, name):
  256. """Convert *name* into an identifier for storing in the database."""
  257. return name.lower().replace("_", "").replace("-", "")
  258. def create_db(self, conn):
  259. """Initialize the notes database with its necessary tables."""
  260. script = """
  261. CREATE TABLE entries (entry_id, entry_slug, entry_title,
  262. entry_revision);
  263. CREATE TABLE users (user_id, user_host);
  264. CREATE TABLE revisions (rev_id, rev_entry, rev_user, rev_timestamp,
  265. rev_content);
  266. """
  267. conn.executescript(script)
  268. def get_next_entry(self, conn):
  269. """Get the next entry ID."""
  270. query = "SELECT MAX(entry_id) FROM entries"
  271. later = conn.execute(query).fetchone()[0]
  272. return later + 1 if later else 1
  273. def get_next_revision(self, conn):
  274. """Get the next revision ID."""
  275. query = "SELECT MAX(rev_id) FROM revisions"
  276. later = conn.execute(query).fetchone()[0]
  277. return later + 1 if later else 1
  278. def get_user(self, conn, host):
  279. """Get the user ID corresponding to a hostname, or make one."""
  280. query1 = "SELECT user_id FROM users WHERE user_host = ?"
  281. query2 = "SELECT MAX(user_id) FROM users"
  282. query3 = "INSERT INTO users VALUES (?, ?)"
  283. user = conn.execute(query1, (host,)).fetchone()
  284. if user:
  285. return user[0]
  286. last = conn.execute(query2).fetchone()[0]
  287. later = last + 1 if last else 1
  288. conn.execute(query3, (later, host))
  289. return later