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.

285 lines
10 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. import socket
  23. from threading import Lock
  24. from time import sleep, time
  25. from earwigbot.exceptions import BrokenSocketError
  26. __all__ = ["IRCConnection"]
  27. class IRCConnection:
  28. """Interface with an IRC server."""
  29. def __init__(self, host, port, nick, ident, realname, logger):
  30. self._host = host
  31. self._port = port
  32. self._nick = nick
  33. self._ident = ident
  34. self._realname = realname
  35. self.logger = logger
  36. self._is_running = False
  37. self._send_lock = Lock()
  38. self._last_recv = time()
  39. self._last_send = 0
  40. self._last_ping = 0
  41. self._myhost = "." * 63 # default: longest possible hostname
  42. def __repr__(self):
  43. """Return the canonical string representation of the IRCConnection."""
  44. res = "IRCConnection(host={0!r}, port={1!r}, nick={2!r}, ident={3!r}, realname={4!r})"
  45. return res.format(self.host, self.port, self.nick, self.ident,
  46. self.realname)
  47. def __str__(self):
  48. """Return a nice string representation of the IRCConnection."""
  49. res = "<IRCConnection {0}!{1} at {2}:{3}>"
  50. return res.format(self.nick, self.ident, self.host, self.port)
  51. def _connect(self):
  52. """Connect to our IRC server."""
  53. self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  54. try:
  55. self._sock.connect((self.host, self.port))
  56. except socket.error:
  57. self.logger.exception("Couldn't connect to IRC server; retrying")
  58. sleep(8)
  59. self._connect()
  60. self._send("NICK {0}".format(self.nick))
  61. self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname))
  62. def _close(self):
  63. """Completely close our connection with the IRC server."""
  64. try:
  65. self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first
  66. except socket.error:
  67. pass # Ignore if the socket is already down
  68. self._sock.close()
  69. def _get(self, size=4096):
  70. """Receive (i.e. get) data from the server."""
  71. data = self._sock.recv(size)
  72. if not data:
  73. # Socket isn't giving us any data, so it is dead or broken:
  74. raise BrokenSocketError()
  75. return data.decode(errors="ignore")
  76. def _send(self, msg, hidelog=False):
  77. """Send data to the server."""
  78. with self._send_lock:
  79. time_since_last = time() - self._last_send
  80. if time_since_last < 0.75:
  81. sleep(0.75 - time_since_last)
  82. try:
  83. self._sock.sendall(msg.encode() + b"\r\n")
  84. except socket.error:
  85. self._is_running = False
  86. else:
  87. if not hidelog:
  88. self.logger.debug(msg)
  89. self._last_send = time()
  90. def _get_maxlen(self, extra):
  91. """Return our best guess of the maximum length of a standard message.
  92. This applies mainly to PRIVMSGs and NOTICEs.
  93. """
  94. base_max = 512
  95. userhost = len(self.nick) + len(self.ident) + len(self._myhost) + 2
  96. padding = 4 # "\r\n" at end, ":" at beginning, and " " after userhost
  97. return base_max - userhost - padding - extra
  98. def _split(self, msgs, extralen, maxsplits=3):
  99. """Split a large message into multiple messages."""
  100. maxlen = self._get_maxlen(extralen)
  101. words = msgs.split(" ")
  102. splits = 0
  103. while words and splits < maxsplits:
  104. splits += 1
  105. if len(words[0]) > maxlen:
  106. word = words.pop(0)
  107. yield word[:maxlen]
  108. words.insert(0, word[maxlen:])
  109. else:
  110. msg = []
  111. while words and len(" ".join(msg + [words[0]])) <= maxlen:
  112. msg.append(words.pop(0))
  113. yield " ".join(msg)
  114. def _quit(self, msg=None):
  115. """Issue a quit message to the server. Doesn't close the connection."""
  116. if msg:
  117. self._send("QUIT :{0}".format(msg))
  118. else:
  119. self._send("QUIT")
  120. def _process_defaults(self, line):
  121. """Default process hooks for lines received on IRC."""
  122. self._last_recv = time()
  123. if line[0] == "PING": # If we are pinged, pong back
  124. self.pong(line[1][1:])
  125. elif line[1] == "001": # Update nickname on startup
  126. if line[2] != self.nick:
  127. self.logger.warn("Nickname changed from {0} to {1}".format(
  128. self.nick, line[2]))
  129. self._nick = line[2]
  130. elif line[1] == "376": # After sign-on, get our userhost
  131. self._send("WHOIS {0}".format(self.nick))
  132. elif line[1] == "311": # Receiving WHOIS result
  133. if line[2] == self.nick:
  134. self._ident = line[4]
  135. self._myhost = line[5]
  136. elif line[1] == "396": # Hostname change
  137. self._myhost = line[3]
  138. def _process_message(self, line):
  139. """To be overridden in subclasses."""
  140. raise NotImplementedError()
  141. @property
  142. def host(self):
  143. """The hostname of the IRC server, like ``"irc.freenode.net"``."""
  144. return self._host
  145. @property
  146. def port(self):
  147. """The port of the IRC server, like ``6667``."""
  148. return self._port
  149. @property
  150. def nick(self):
  151. """Our nickname on the server, like ``"EarwigBot"``."""
  152. return self._nick
  153. @property
  154. def ident(self):
  155. """Our ident on the server, like ``"earwig"``.
  156. See https://en.wikipedia.org/wiki/Ident_protocol.
  157. """
  158. return self._ident
  159. @property
  160. def realname(self):
  161. """Our realname (gecos field) on the server."""
  162. return self._realname
  163. def say(self, target, msg, hidelog=False):
  164. """Send a private message to a target on the server."""
  165. for msg in self._split(msg, len(target) + 10):
  166. msg = "PRIVMSG {0} :{1}".format(target, msg)
  167. self._send(msg, hidelog)
  168. def reply(self, data, msg, hidelog=False):
  169. """Send a private message as a reply to a user on the server."""
  170. if data.is_private:
  171. self.say(data.chan, msg, hidelog)
  172. else:
  173. msg = "\x02{0}\x0F: {1}".format(data.reply_nick, msg)
  174. self.say(data.chan, msg, hidelog)
  175. def action(self, target, msg, hidelog=False):
  176. """Send a private message to a target on the server as an action."""
  177. msg = "\x01ACTION {0}\x01".format(msg)
  178. self.say(target, msg, hidelog)
  179. def notice(self, target, msg, hidelog=False):
  180. """Send a notice to a target on the server."""
  181. for msg in self._split(msg, len(target) + 9):
  182. msg = "NOTICE {0} :{1}".format(target, msg)
  183. self._send(msg, hidelog)
  184. def join(self, chan, hidelog=False):
  185. """Join a channel on the server."""
  186. msg = "JOIN {0}".format(chan)
  187. self._send(msg, hidelog)
  188. def part(self, chan, msg=None, hidelog=False):
  189. """Part from a channel on the server, optionally using an message."""
  190. if msg:
  191. self._send("PART {0} :{1}".format(chan, msg), hidelog)
  192. else:
  193. self._send("PART {0}".format(chan), hidelog)
  194. def mode(self, target, level, msg, hidelog=False):
  195. """Send a mode message to the server."""
  196. msg = "MODE {0} {1} {2}".format(target, level, msg)
  197. self._send(msg, hidelog)
  198. def ping(self, target, hidelog=False):
  199. """Ping another entity on the server."""
  200. msg = "PING {0}".format(target)
  201. self._send(msg, hidelog)
  202. def pong(self, target, hidelog=False):
  203. """Pong another entity on the server."""
  204. msg = "PONG {0}".format(target)
  205. self._send(msg, hidelog)
  206. def loop(self):
  207. """Main loop for the IRC connection."""
  208. self._is_running = True
  209. read_buffer = ""
  210. while 1:
  211. try:
  212. read_buffer += self._get()
  213. except BrokenSocketError:
  214. self._is_running = False
  215. break
  216. lines = read_buffer.split("\n")
  217. read_buffer = lines.pop()
  218. for line in lines:
  219. line = line.strip().split()
  220. self._process_defaults(line)
  221. self._process_message(line)
  222. if self.is_stopped():
  223. break
  224. self._close()
  225. def keep_alive(self):
  226. """Ensure that we stay connected, stopping if the connection breaks."""
  227. now = time()
  228. if now - self._last_recv > 120:
  229. if self._last_ping < self._last_recv:
  230. log = "Last message was received over 120 seconds ago. Pinging."
  231. self.logger.debug(log)
  232. self.ping(self.host)
  233. self._last_ping = now
  234. elif now - self._last_ping > 60:
  235. self.logger.debug("No ping response in 60 seconds. Stopping.")
  236. self.stop()
  237. def stop(self, msg=None):
  238. """Request the IRC connection to close at earliest convenience."""
  239. if self._is_running:
  240. self._quit(msg)
  241. self._is_running = False
  242. def is_stopped(self):
  243. """Return whether the IRC connection has been (or is to be) closed."""
  244. return not self._is_running