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 13 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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  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