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.

355 lines
14 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2021 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 ast import literal_eval
  23. import re
  24. from earwigbot.commands import Command
  25. from earwigbot.irc import RC
  26. class Stalk(Command):
  27. """Stalk a particular user (!stalk/!unstalk) or page (!watch/!unwatch) for
  28. edits. Prefix regular expressions with "re:" (uses re.match)."""
  29. name = "stalk"
  30. commands = ["stalk", "watch", "unstalk", "unwatch", "stalks", "watches",
  31. "allstalks", "allwatches", "unstalkall", "unwatchall"]
  32. hooks = ["msg", "rc"]
  33. MAX_STALKS_PER_USER = 5
  34. def setup(self):
  35. self._users = {}
  36. self._pages = {}
  37. self._load_stalks()
  38. def check(self, data):
  39. if isinstance(data, RC):
  40. return True
  41. if data.is_command and data.command in self.commands:
  42. return True
  43. return False
  44. def process(self, data):
  45. if isinstance(data, RC):
  46. self._process_rc(data)
  47. return
  48. data.is_admin = self.config.irc["permissions"].is_admin(data)
  49. if data.command.startswith("all"):
  50. if data.is_admin:
  51. self.reply(data, self._all_stalks())
  52. else:
  53. self.reply(data, "You must be a bot admin to view all stalked "
  54. "users or watched pages. View your own with "
  55. "\x0306!stalks\x0F.")
  56. return
  57. if data.command.endswith("all"):
  58. if not data.is_admin:
  59. self.reply(data, "You must be a bot admin to unstalk a user "
  60. "or unwatch a page for all users.")
  61. return
  62. if not data.args:
  63. self.reply(data, "You must give a user to unstalk or a page "
  64. "to unwatch. View all active with "
  65. "\x0306!allstalks\x0F.")
  66. return
  67. if not data.args or data.command in ["stalks", "watches"]:
  68. self.reply(data, self._current_stalks(data.nick))
  69. return
  70. modifiers = {}
  71. for modifier in ["noping", "nobots", "nominor", "nocolor"]:
  72. if "!" + modifier in data.args:
  73. modifiers[modifier] = True
  74. data.args.remove("!" + modifier)
  75. target = " ".join(data.args).replace("_", " ")
  76. if target.startswith("[[") and target.endswith("]]"):
  77. target = target[2:-2]
  78. if target.startswith("re:"):
  79. target = "re:" + target[3:].lstrip()
  80. else:
  81. if target.startswith("User:") and "stalk" in data.command:
  82. target = target[5:]
  83. target = target[0].upper() + target[1:]
  84. if data.command in ["stalk", "watch"]:
  85. if data.is_private:
  86. stalkinfo = (data.nick, None, modifiers)
  87. elif not data.is_admin:
  88. self.reply(data, "You must be a bot admin to stalk users or "
  89. "watch pages publicly. Retry this command in "
  90. "a private message.")
  91. return
  92. else:
  93. stalkinfo = (data.nick, data.chan, modifiers)
  94. if data.command == "stalk":
  95. self._add_stalk("user", data, target, stalkinfo)
  96. elif data.command == "watch":
  97. self._add_stalk("page", data, target, stalkinfo)
  98. elif data.command == "unstalk":
  99. self._remove_stalk("user", data, target)
  100. elif data.command == "unwatch":
  101. self._remove_stalk("page", data, target)
  102. elif data.command == "unstalkall":
  103. self._remove_all_stalks("user", data, target)
  104. elif data.command == "unwatchall":
  105. self._remove_all_stalks("page", data, target)
  106. def _process_rc(self, rc):
  107. """Process a watcher event."""
  108. def _update_chans(items, flags):
  109. for item in items:
  110. modifiers = item[2] if len(item) > 2 else {}
  111. if modifiers.get("nobots") and "B" in flags:
  112. continue
  113. if modifiers.get("nominor") and "M" in flags:
  114. continue
  115. if item[1]:
  116. if modifiers.get("noping"):
  117. if item[1] not in chans:
  118. chans[item[1]] = set()
  119. elif item[1] in chans:
  120. chans[item[1]].add(item[0])
  121. else:
  122. chans[item[1]] = {item[0]}
  123. if modifiers.get("nocolor"):
  124. nocolor.add(item[1])
  125. else:
  126. chans[item[0]] = None
  127. if modifiers.get("nocolor"):
  128. nocolor.add(item[0])
  129. def _regex_match(target, tag):
  130. return target.startswith("re:") and re.match(target[3:], tag)
  131. def _process(table, tag, flags):
  132. for target, stalks in table.items():
  133. if target == tag or _regex_match(target, tag):
  134. _update_chans(stalks, flags)
  135. chans = {}
  136. nocolor = set()
  137. _process(self._users, rc.user, rc.flags)
  138. if rc.is_edit:
  139. _process(self._pages, rc.page, rc.flags)
  140. if not chans:
  141. return
  142. with self.bot.component_lock:
  143. frontend = self.bot.frontend
  144. if frontend and not frontend.is_stopped():
  145. for chan, users in chans.items():
  146. if chan.startswith("#") and chan not in frontend.channels:
  147. continue
  148. pretty = rc.prettify(color=chan not in nocolor)
  149. if users:
  150. nicks = ", ".join(sorted(users))
  151. msg = "\x02{0}\x0F: {1}".format(nicks, pretty)
  152. else:
  153. msg = pretty
  154. if len(msg) > 400:
  155. msg = msg[:397] + "..."
  156. frontend.say(chan, msg)
  157. @staticmethod
  158. def _get_stalks_by_nick(nick, table):
  159. """Return a dictionary of stalklist entries by the given nick."""
  160. entries = {}
  161. for target, stalks in table.items():
  162. for info in stalks:
  163. if info[0] == nick:
  164. if target in entries:
  165. entries[target].append(info[1])
  166. else:
  167. entries[target] = [info[1]]
  168. return entries
  169. def _add_stalk(self, stalktype, data, target, stalkinfo):
  170. """Add a stalk entry to the given table."""
  171. if stalktype == "user":
  172. table = self._users
  173. verb = "stalk"
  174. else:
  175. table = self._pages
  176. verb = "watch"
  177. if not data.is_admin:
  178. nstalks = len(self._get_stalks_by_nick(data.nick, table))
  179. if nstalks >= self.MAX_STALKS_PER_USER:
  180. msg = ("Already {0}ing {1} {2}s for you, which is the limit "
  181. "for non-bot admins.")
  182. self.reply(data, msg.format(verb, nstalks, stalktype))
  183. return
  184. if stalkinfo[1] and not stalkinfo[1].startswith("##"):
  185. msg = "You must be a bot admin to {0} {1}s in public channels."
  186. self.reply(data, msg.format(verb, stalktype))
  187. return
  188. if target in table:
  189. if stalkinfo in table[target]:
  190. msg = "Already {0}ing that {1} in here for you."
  191. self.reply(data, msg.format(verb, stalktype))
  192. return
  193. else:
  194. table[target].append(stalkinfo)
  195. else:
  196. table[target] = [stalkinfo]
  197. msg = "Now {0}ing {1} \x0302{2}\x0F. Remove with \x0306!un{0} {2}\x0F."
  198. self.reply(data, msg.format(verb, stalktype, target))
  199. self._save_stalks()
  200. def _remove_stalk(self, stalktype, data, target):
  201. """Remove a stalk entry from the given table."""
  202. if stalktype == "user":
  203. table = self._users
  204. verb = "stalk"
  205. plural = "stalks"
  206. else:
  207. table = self._pages
  208. verb = "watch"
  209. plural = "watches"
  210. to_remove = []
  211. if target in table:
  212. for info in table[target]:
  213. if info[0] == data.nick:
  214. to_remove.append(info)
  215. if not to_remove:
  216. msg = ("I haven't been {0}ing that {1} for you in the first "
  217. "place. View your active {2} with \x0306!{2}\x0F.")
  218. if data.is_admin:
  219. msg += (" As a bot admin, you can clear all active {2} on "
  220. "that {1} with \x0306!un{0}all {3}\x0F.")
  221. self.reply(data, msg.format(verb, stalktype, plural, target))
  222. return
  223. for info in to_remove:
  224. table[target].remove(info)
  225. if not table[target]:
  226. del table[target]
  227. msg = "No longer {0}ing {1} \x0302{2}\x0F for you."
  228. self.reply(data, msg.format(verb, stalktype, target))
  229. self._save_stalks()
  230. def _remove_all_stalks(self, stalktype, data, target):
  231. """Remove all entries for a particular target from the given table."""
  232. if stalktype == "user":
  233. table = self._users
  234. verb = "stalk"
  235. plural = "stalks"
  236. else:
  237. table = self._pages
  238. verb = "watch"
  239. plural = "watches"
  240. try:
  241. del table[target]
  242. except KeyError:
  243. msg = ("I haven't been {0}ing that {1} for anyone in the first "
  244. "place. View all active {2} with \x0306!all{2}\x0F.")
  245. self.reply(data, msg.format(verb, stalktype, plural))
  246. else:
  247. msg = "No longer {0}ing {1} \x0302{2}\x0F for anyone."
  248. self.reply(data, msg.format(verb, stalktype, target))
  249. self._save_stalks()
  250. def _current_stalks(self, nick):
  251. """Return the given user's current stalks."""
  252. def _format_chans(chans):
  253. if None in chans:
  254. chans.remove(None)
  255. if not chans:
  256. return "privately"
  257. if len(chans) == 1:
  258. return "in {0} and privately".format(chans[0])
  259. return "in " + ", ".join(chans) + ", and privately"
  260. return "in " + ", ".join(chans)
  261. def _format_stalks(stalks):
  262. return ", ".join(
  263. "\x0302{0}\x0F ({1})".format(target, _format_chans(chans))
  264. for target, chans in stalks.items())
  265. users = self._get_stalks_by_nick(nick, self._users)
  266. pages = self._get_stalks_by_nick(nick, self._pages)
  267. if users:
  268. uinfo = " Users: {0}.".format(_format_stalks(users))
  269. if pages:
  270. pinfo = " Pages: {0}.".format(_format_stalks(pages))
  271. msg = "Currently stalking {0} user{1} and watching {2} page{3} for you.{4}{5}"
  272. return msg.format(len(users), "s" if len(users) != 1 else "",
  273. len(pages), "s" if len(pages) != 1 else "",
  274. uinfo if users else "", pinfo if pages else "")
  275. def _all_stalks(self):
  276. """Return all existing stalks, for bot admins."""
  277. def _format_info(info):
  278. if info[1]:
  279. result = "for {0} in {1}".format(info[0], info[1])
  280. else:
  281. result = "for {0} privately".format(info[0])
  282. modifiers = ", ".join(info[2]) if len(info) > 2 else ""
  283. if modifiers:
  284. result += " ({0})".format(modifiers)
  285. return result
  286. def _format_data(data):
  287. return ", ".join(_format_info(info) for info in data)
  288. def _format_stalks(stalks):
  289. return ", ".join(
  290. "\x0302{0}\x0F ({1})".format(target, _format_data(data))
  291. for target, data in stalks.items())
  292. users, pages = self._users, self._pages
  293. if users:
  294. uinfo = " Users: {0}.".format(_format_stalks(users))
  295. if pages:
  296. pinfo = " Pages: {0}.".format(_format_stalks(pages))
  297. msg = "Currently stalking {0} user{1} and watching {2} page{3}.{4}{5}"
  298. return msg.format(len(users), "s" if len(users) != 1 else "",
  299. len(pages), "s" if len(pages) != 1 else "",
  300. uinfo if users else "", pinfo if pages else "")
  301. def _load_stalks(self):
  302. """Load saved stalks from the database."""
  303. permdb = self.config.irc["permissions"]
  304. try:
  305. data = permdb.get_attr("command:stalk", "data")
  306. except KeyError:
  307. return
  308. self._users, self._pages = literal_eval(data)
  309. def _save_stalks(self):
  310. """Save stalks to the database."""
  311. permdb = self.config.irc["permissions"]
  312. data = str((self._users, self._pages))
  313. permdb.set_attr("command:stalk", "data", data)