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.

notes.py 14 KiB

11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  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