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.

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