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.

282 regels
10 KiB

  1. # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
  2. #
  3. # Permission is hereby granted, free of charge, to any person obtaining a copy
  4. # of this software and associated documentation files (the "Software"), to deal
  5. # in the Software without restriction, including without limitation the rights
  6. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  7. # copies of the Software, and to permit persons to whom the Software is
  8. # furnished to do so, subject to the following conditions:
  9. #
  10. # The above copyright notice and this permission notice shall be included in
  11. # all copies or substantial portions of the Software.
  12. #
  13. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  14. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  15. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  16. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  17. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  18. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  19. # SOFTWARE.
  20. import socket
  21. from threading import Lock
  22. from time import sleep, time
  23. from earwigbot.exceptions import BrokenSocketError
  24. __all__ = ["IRCConnection"]
  25. class IRCConnection:
  26. """Interface with an IRC server."""
  27. def __init__(self, host, port, nick, ident, realname, logger):
  28. self._host = host
  29. self._port = port
  30. self._nick = nick
  31. self._ident = ident
  32. self._realname = realname
  33. self.logger = logger
  34. self._is_running = False
  35. self._send_lock = Lock()
  36. self._last_recv = time()
  37. self._last_send = 0
  38. self._last_ping = 0
  39. self._myhost = "." * 63 # default: longest possible hostname
  40. def __repr__(self):
  41. """Return the canonical string representation of the IRCConnection."""
  42. res = "IRCConnection(host={0!r}, port={1!r}, nick={2!r}, ident={3!r}, realname={4!r})"
  43. return res.format(self.host, self.port, self.nick, self.ident, self.realname)
  44. def __str__(self):
  45. """Return a nice string representation of the IRCConnection."""
  46. res = "<IRCConnection {0}!{1} at {2}:{3}>"
  47. return res.format(self.nick, self.ident, self.host, self.port)
  48. def _connect(self):
  49. """Connect to our IRC server."""
  50. self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  51. try:
  52. self._sock.connect((self.host, self.port))
  53. except OSError:
  54. self.logger.exception("Couldn't connect to IRC server; retrying")
  55. sleep(8)
  56. self._connect()
  57. self._send(f"NICK {self.nick}")
  58. self._send(f"USER {self.ident} {self.host} * :{self.realname}")
  59. def _close(self):
  60. """Completely close our connection with the IRC server."""
  61. try:
  62. self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first
  63. except OSError:
  64. pass # Ignore if the socket is already down
  65. self._sock.close()
  66. def _get(self, size=4096):
  67. """Receive (i.e. get) data from the server."""
  68. data = self._sock.recv(size)
  69. if not data:
  70. # Socket isn't giving us any data, so it is dead or broken:
  71. raise BrokenSocketError()
  72. return data.decode(errors="ignore")
  73. def _send(self, msg, hidelog=False):
  74. """Send data to the server."""
  75. with self._send_lock:
  76. time_since_last = time() - self._last_send
  77. if time_since_last < 0.75:
  78. sleep(0.75 - time_since_last)
  79. try:
  80. self._sock.sendall(msg.encode() + b"\r\n")
  81. except OSError:
  82. self._is_running = False
  83. else:
  84. if not hidelog:
  85. self.logger.debug(msg)
  86. self._last_send = time()
  87. def _get_maxlen(self, extra):
  88. """Return our best guess of the maximum length of a standard message.
  89. This applies mainly to PRIVMSGs and NOTICEs.
  90. """
  91. base_max = 512
  92. userhost = len(self.nick) + len(self.ident) + len(self._myhost) + 2
  93. padding = 4 # "\r\n" at end, ":" at beginning, and " " after userhost
  94. return base_max - userhost - padding - extra
  95. def _split(self, msgs, extralen, maxsplits=3):
  96. """Split a large message into multiple messages."""
  97. maxlen = self._get_maxlen(extralen)
  98. words = msgs.split(" ")
  99. splits = 0
  100. while words and splits < maxsplits:
  101. splits += 1
  102. if len(words[0]) > maxlen:
  103. word = words.pop(0)
  104. yield word[:maxlen]
  105. words.insert(0, word[maxlen:])
  106. else:
  107. msg = []
  108. while words and len(" ".join(msg + [words[0]])) <= maxlen:
  109. msg.append(words.pop(0))
  110. yield " ".join(msg)
  111. def _quit(self, msg=None):
  112. """Issue a quit message to the server. Doesn't close the connection."""
  113. if msg:
  114. self._send(f"QUIT :{msg}")
  115. else:
  116. self._send("QUIT")
  117. def _process_defaults(self, line):
  118. """Default process hooks for lines received on IRC."""
  119. self._last_recv = time()
  120. if line[0] == "PING": # If we are pinged, pong back
  121. self.pong(line[1][1:])
  122. elif line[1] == "001": # Update nickname on startup
  123. if line[2] != self.nick:
  124. self.logger.warn(f"Nickname changed from {self.nick} to {line[2]}")
  125. self._nick = line[2]
  126. elif line[1] == "376": # After sign-on, get our userhost
  127. self._send(f"WHOIS {self.nick}")
  128. elif line[1] == "311": # Receiving WHOIS result
  129. if line[2] == self.nick:
  130. self._ident = line[4]
  131. self._myhost = line[5]
  132. elif line[1] == "396": # Hostname change
  133. self._myhost = line[3]
  134. def _process_message(self, line):
  135. """To be overridden in subclasses."""
  136. raise NotImplementedError()
  137. @property
  138. def host(self):
  139. """The hostname of the IRC server, like ``"irc.libera.chat"``."""
  140. return self._host
  141. @property
  142. def port(self):
  143. """The port of the IRC server, like ``6667``."""
  144. return self._port
  145. @property
  146. def nick(self):
  147. """Our nickname on the server, like ``"EarwigBot"``."""
  148. return self._nick
  149. @property
  150. def ident(self):
  151. """Our ident on the server, like ``"earwig"``.
  152. See https://en.wikipedia.org/wiki/Ident_protocol.
  153. """
  154. return self._ident
  155. @property
  156. def realname(self):
  157. """Our realname (gecos field) on the server."""
  158. return self._realname
  159. def say(self, target, msg, hidelog=False):
  160. """Send a private message to a target on the server."""
  161. for msg in self._split(msg, len(target) + 10):
  162. msg = f"PRIVMSG {target} :{msg}"
  163. self._send(msg, hidelog)
  164. def reply(self, data, msg, hidelog=False):
  165. """Send a private message as a reply to a user on the server."""
  166. if data.is_private:
  167. self.say(data.chan, msg, hidelog)
  168. else:
  169. msg = f"\x02{data.reply_nick}\x0f: {msg}"
  170. self.say(data.chan, msg, hidelog)
  171. def action(self, target, msg, hidelog=False):
  172. """Send a private message to a target on the server as an action."""
  173. msg = f"\x01ACTION {msg}\x01"
  174. self.say(target, msg, hidelog)
  175. def notice(self, target, msg, hidelog=False):
  176. """Send a notice to a target on the server."""
  177. for msg in self._split(msg, len(target) + 9):
  178. msg = f"NOTICE {target} :{msg}"
  179. self._send(msg, hidelog)
  180. def join(self, chan, hidelog=False):
  181. """Join a channel on the server."""
  182. msg = f"JOIN {chan}"
  183. self._send(msg, hidelog)
  184. def part(self, chan, msg=None, hidelog=False):
  185. """Part from a channel on the server, optionally using an message."""
  186. if msg:
  187. self._send(f"PART {chan} :{msg}", hidelog)
  188. else:
  189. self._send(f"PART {chan}", hidelog)
  190. def mode(self, target, level, msg, hidelog=False):
  191. """Send a mode message to the server."""
  192. msg = f"MODE {target} {level} {msg}"
  193. self._send(msg, hidelog)
  194. def ping(self, target, hidelog=False):
  195. """Ping another entity on the server."""
  196. msg = f"PING {target}"
  197. self._send(msg, hidelog)
  198. def pong(self, target, hidelog=False):
  199. """Pong another entity on the server."""
  200. msg = f"PONG {target}"
  201. self._send(msg, hidelog)
  202. def loop(self):
  203. """Main loop for the IRC connection."""
  204. self._is_running = True
  205. read_buffer = ""
  206. while 1:
  207. try:
  208. read_buffer += self._get()
  209. except BrokenSocketError:
  210. self._is_running = False
  211. break
  212. lines = read_buffer.split("\n")
  213. read_buffer = lines.pop()
  214. for line in lines:
  215. line = line.strip().split()
  216. self._process_defaults(line)
  217. self._process_message(line)
  218. if self.is_stopped():
  219. break
  220. self._close()
  221. def keep_alive(self):
  222. """Ensure that we stay connected, stopping if the connection breaks."""
  223. now = time()
  224. if now - self._last_recv > 120:
  225. if self._last_ping < self._last_recv:
  226. log = "Last message was received over 120 seconds ago. Pinging."
  227. self.logger.debug(log)
  228. self.ping(self.host)
  229. self._last_ping = now
  230. elif now - self._last_ping > 60:
  231. self.logger.debug("No ping response in 60 seconds. Stopping.")
  232. self.stop()
  233. def stop(self, msg=None):
  234. """Request the IRC connection to close at earliest convenience."""
  235. if self._is_running:
  236. self._quit(msg)
  237. self._is_running = False
  238. def is_stopped(self):
  239. """Return whether the IRC connection has been (or is to be) closed."""
  240. return not self._is_running