Automatic IRC voicing based on activity.
irc
irc-bot
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.

voicebot.py 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. #!/usr/bin/env python3
  2. # Copyright (C) 2017 nickolas360 <contact@nickolas360.com>
  3. #
  4. # This file is part of voicebot.
  5. #
  6. # voicebot is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as
  8. # published by the Free Software Foundation, either version 3 of the
  9. # License, or (at your option) any later version.
  10. #
  11. # As an additional permission under GNU AGPL version 3 section 7, if a
  12. # modified version of this Program responds to the message "help" in an
  13. # IRC query with an opportunity to receive the Corresponding Source, it
  14. # satisfies the requirement to "prominently offer" such an opportunity.
  15. # All other requirements in the first paragraph of section 13 must still
  16. # be met.
  17. #
  18. # voicebot is distributed in the hope that it will be useful,
  19. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  20. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  21. # GNU Affero General Public License for more details.
  22. #
  23. # You should have received a copy of the GNU Affero General Public License
  24. # along with voicebot. If not, see <http://www.gnu.org/licenses/>.
  25. """
  26. Usage:
  27. voicebot [options] <host> <port> <nickname> <channel>
  28. voicebot -h | --help | --version
  29. Options:
  30. -t --time <seconds> How long to wait before devoicing inactive users.
  31. [default: 86400]. (86400 seconds == 1 day)
  32. -f --force-id Force users to be logged in with NickServ.
  33. -x --prefixes <chars> Allow users with any of these prefixes to operate
  34. voicebot [default: @].
  35. -p --password Read an IRC password from standard input.
  36. -P --passfile <file> Read an IRC password from the specified file.
  37. -S --sasl <account> Use SASL authentication with the specified account.
  38. -s --ssl Use SSL/TLS to connect to the IRC server.
  39. -v --verbose Display communication with the IRC server.
  40. """
  41. from docopt import docopt
  42. from pyrcb2 import IRCBot, Event, astdio, IDict, IDefaultDict
  43. from getpass import getpass
  44. import asyncio
  45. import json
  46. import os
  47. import sys
  48. import time
  49. __version__ = "0.1.1"
  50. # If modified, update this URL to point to the modified version.
  51. SOURCE_URL = "https://github.com/nickolas360/voicebot"
  52. HELP_MESSAGE = """\
  53. Source: {0} (AGPLv3 or later)
  54. """.format(SOURCE_URL)
  55. SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
  56. NICKNAMES_PATH = os.path.join(SCRIPT_DIR, "nicknames")
  57. ACCOUNTS_PATH = os.path.join(SCRIPT_DIR, "accounts")
  58. DATA_PATH = os.path.join(SCRIPT_DIR, "voicebot-data")
  59. NONE = (None,) # Sentinel
  60. COMMAND_LOOP_HELP = """\
  61. Commands:
  62. add-nickname <nickname>
  63. add-account <account>
  64. remove-nickname <nickname>
  65. remove-account <account>
  66. list-nicknames
  67. list-accounts
  68. """.rstrip("\n")
  69. ARG_COUNT = {
  70. "add-nickname": 1,
  71. "add-account": 1,
  72. "remove-nickname": 1,
  73. "remove-account": 1,
  74. "list-nicknames": 0,
  75. "list-accounts": 0,
  76. }
  77. class Voicebot:
  78. def __init__(self, channel, duration, force_id, prefixes, verbose):
  79. self.channel = channel
  80. self.duration = duration
  81. self.force_id = force_id
  82. self.prefixes = frozenset(prefixes)
  83. self.nicknames = IDict()
  84. self.accounts = IDict()
  85. self.nickname_last_message_times = IDefaultDict(time.time)
  86. self.account_last_message_times = IDefaultDict(time.time)
  87. self.invalid_cmd_counts = IDict()
  88. self.bot = IRCBot(log_communication=verbose)
  89. self.bot.track_known_id_statuses = self.force_id
  90. self.bot.load_events(self)
  91. self.load()
  92. def load(self):
  93. self.nicknames.update((n, None) for n in read_lines(NICKNAMES_PATH))
  94. self.accounts.update((a, None) for a in read_lines(ACCOUNTS_PATH))
  95. lines = read_lines(DATA_PATH)
  96. data = json.loads("\n".join(lines)) if lines else [{}, {}]
  97. self.nickname_last_message_times.update(data[0])
  98. self.account_last_message_times.update(data[1])
  99. self.filter_times()
  100. def save(self):
  101. write_lines(NICKNAMES_PATH, list(self.nicknames))
  102. write_lines(ACCOUNTS_PATH, list(self.accounts))
  103. self.filter_times()
  104. with open(DATA_PATH, "w") as f:
  105. json.dump([
  106. self.nickname_last_message_times,
  107. self.account_last_message_times,
  108. ], f)
  109. def filter_times(self):
  110. for nickname in self.nickname_last_message_times:
  111. if nickname not in self.nicknames:
  112. del self.nickname_last_message_times[nickname]
  113. for account in self.account_last_message_times:
  114. if account not in self.accounts:
  115. del self.account_last_message_times[account]
  116. def start(self, host, port, ssl, nick, password, sasl_account):
  117. self.bot.schedule_coroutine(self.command_loop())
  118. self.bot.call_coroutine(self.start_async(
  119. host, port, ssl, nick, password, sasl_account,
  120. ))
  121. async def start_async(self, host, port, ssl, nick, password, sasl_account):
  122. await self.bot.connect(host, port, ssl=ssl)
  123. if sasl_account:
  124. await self.bot.sasl_auth(sasl_account, password)
  125. password = None
  126. await self.bot.register(nick, password=password)
  127. result = await self.bot.join(self.channel)
  128. if not result.success:
  129. raise result.to_exception("Could not join channel")
  130. interval = min(self.duration / 4, 60)
  131. self.bot.schedule_coroutine(self.devoice_loop(interval))
  132. await self.bot.listen()
  133. @Event.privmsg
  134. async def on_privmsg(self, sender, channel, message):
  135. op_cmd = False
  136. if sender in self.bot.users[self.channel]:
  137. if self.bot.users[self.channel][sender].prefixes & self.prefixes:
  138. op_cmd = self.on_op_message(sender, channel, message)
  139. if channel is None:
  140. if not op_cmd:
  141. self.on_query(sender, message)
  142. return
  143. await self.check_voice(sender)
  144. def on_query(self, sender, message):
  145. if message.lower() == "help":
  146. for line in HELP_MESSAGE.splitlines():
  147. self.bot.privmsg(sender, line)
  148. else:
  149. if self.invalid_cmd_allowed(sender):
  150. self.bot.privmsg(sender, 'Type "help" for help.')
  151. return
  152. self.valid_cmd_received(sender)
  153. def on_op_message(self, sender, channel, message):
  154. if channel is not None:
  155. if not message.startswith(self.bot.nickname + ": "):
  156. return False
  157. message = (message.split(None, 1)[1:] or [""])[0]
  158. try:
  159. command, *args = message.split()
  160. except ValueError:
  161. return False
  162. commands = [
  163. "add-nickname", "add-account", "remove-nickname", "remove-account"]
  164. if not (command in commands and len(args) == ARG_COUNT[command]):
  165. return False
  166. response = self.handle_command(command, *args)
  167. if channel is not None:
  168. response = sender + ": " + response
  169. self.valid_cmd_received(sender)
  170. self.bot.privmsg(channel or sender, response)
  171. return True
  172. @Event.nick
  173. async def on_nick(self, sender, nickname):
  174. await self.refresh_voice_status(nickname)
  175. @Event.join
  176. async def on_join(self, sender, channel):
  177. await self.refresh_voice_status(sender, update_times=False)
  178. @Event.command("ACCOUNT")
  179. async def on_account(self, sender, account):
  180. await self.refresh_voice_status(sender, update_times=False)
  181. async def refresh_voice_status(self, nickname, update_times=True):
  182. user = self.bot.users[self.channel][nickname]
  183. if user.has_prefix("+"):
  184. await self.check_devoice(nickname)
  185. return
  186. await self.check_voice(nickname, update_times)
  187. async def check_voice(self, nickname, update_times=True):
  188. account, end_early = await self.get_account(nickname)
  189. if account is NONE:
  190. return
  191. if not (nickname in self.nicknames or account in self.accounts):
  192. return
  193. if self.force_id and (await self.get_id_status(nickname)) != 3:
  194. return
  195. if update_times and nickname in self.nicknames:
  196. self.nickname_last_message_times[nickname] = time.time()
  197. if update_times and account in self.accounts:
  198. self.account_last_message_times[account] = time.time()
  199. if not update_times:
  200. msg_time = self.get_last_message_time(nickname, account)
  201. if time.time() - msg_time > self.duration:
  202. return
  203. if not self.bot.users[self.channel][nickname].has_prefix("+"):
  204. print("Voicing {}...".format(nickname))
  205. self.bot.send_command("MODE", self.channel, "+v", nickname)
  206. async def check_devoice(self, nickname):
  207. account, end_early = await self.get_account(nickname)
  208. if account is NONE or end_early:
  209. return
  210. devoice = not (nickname in self.nicknames or account in self.accounts)
  211. if self.force_id and not devoice:
  212. devoice = (await self.get_id_status(nickname) != 3)
  213. if not devoice:
  214. msg_time = self.get_last_message_time(nickname, account)
  215. devoice = time.time() - msg_time > self.duration
  216. if devoice:
  217. print("Devoicing {}...".format(nickname))
  218. self.bot.send_command("MODE", self.channel, "-v", nickname)
  219. async def get_account(self, nickname):
  220. will_call_known_event = (
  221. self.bot.is_tracking_known_accounts and
  222. not self.bot.is_account_synced(nickname))
  223. result = await self.bot.get_account(nickname)
  224. if not result.success:
  225. stderr("Could not get account:", result.to_exception())
  226. return NONE, False
  227. return result.value, will_call_known_event
  228. async def get_id_status(self, nickname):
  229. result = await self.bot.get_id_status(nickname)
  230. if not result.success:
  231. stderr("Could not get ID status:", result.to_exception())
  232. return None
  233. return result.value
  234. def get_users(self, voiced):
  235. users = []
  236. for user in self.bot.users[self.channel].values():
  237. if user.has_prefix("+") == bool(voiced):
  238. users.append(user)
  239. return users
  240. def get_last_message_time(self, nickname, account):
  241. times = []
  242. if nickname in self.nicknames:
  243. times.append(self.nickname_last_message_times[nickname])
  244. if account in self.accounts:
  245. times.append(self.account_last_message_times[account])
  246. if not times:
  247. raise ValueError("User is not managed by voicebot.")
  248. return max(times)
  249. def invalid_cmd_allowed(self, sender, max_invalid=10):
  250. count, _ = self.invalid_cmd_counts.get(sender, (0, None))
  251. self.invalid_cmd_counts[sender] = (count + 1, time.time())
  252. self.invalid_cmd_collect_garbage()
  253. return count <= max_invalid
  254. def valid_cmd_received(self, sender):
  255. self.invalid_cmd_counts.pop(sender, None)
  256. def invalid_cmd_collect_garbage(self, timeout=120):
  257. now = time.time()
  258. while self.invalid_cmd_counts:
  259. _, last_time = next(iter(self.invalid_cmd_counts.values()))
  260. if now - last_time < timeout:
  261. break
  262. self.invalid_cmd_counts.popitem(last=False)
  263. async def devoice_loop(self, interval=60):
  264. while True:
  265. await asyncio.sleep(interval)
  266. if self.channel in self.bot.channels:
  267. await self.bot.gather(*(
  268. self.check_devoice(user) for user in self.get_users(True)
  269. ))
  270. def handle_command(self, command, *args):
  271. if command == "add-nickname":
  272. self.nicknames[args[0]] = None
  273. return "Nickname added."
  274. if command == "add-account":
  275. self.accounts[args[0]] = None
  276. return "Account added."
  277. if command == "remove-nickname":
  278. self.nicknames.pop(args[0], None)
  279. self.nickname_last_message_times.pop(args[0], None)
  280. return "Nickname removed."
  281. if command == "remove-account":
  282. self.accounts.pop(args[0], None)
  283. self.account_last_message_times.pop(args[0], None)
  284. return "Account removed."
  285. if command == "list-nicknames":
  286. return "\n".join(self.nicknames)
  287. if command == "list-accounts":
  288. return "\n".join(self.accounts)
  289. return None
  290. async def command_loop(self):
  291. while True:
  292. try:
  293. args = (await astdio.input()).split()
  294. except EOFError:
  295. break
  296. text = COMMAND_LOOP_HELP
  297. if args and len(args) == ARG_COUNT.get(args[0], -1) + 1:
  298. text = self.handle_command(*args)
  299. if text:
  300. await astdio.print(text, file=sys.stderr)
  301. def read_lines(path):
  302. try:
  303. with open(path) as f:
  304. return f.read().splitlines()
  305. except FileNotFoundError:
  306. return []
  307. def write_lines(path, lines):
  308. if not os.path.exists(path) and not lines:
  309. return
  310. with open(path, "w") as f:
  311. for line in lines:
  312. print(line, file=f)
  313. def stderr(*args, **kwargs):
  314. kwargs.setdefault("file", sys.stderr)
  315. print(*args, **kwargs)
  316. def main(argv):
  317. args = docopt(__doc__, argv=argv[1:], version=__version__)
  318. password = None
  319. if args["--passfile"] is not None:
  320. with open(args["--passfile"]) as f:
  321. password = f.read().strip("\r\n")
  322. elif args["--password"] or args["--sasl"] is not None:
  323. print("Password: ", end="", file=sys.stderr, flush=True)
  324. password = getpass("") if sys.stdin.isatty() else input()
  325. if not sys.stdin.isatty():
  326. print("Received password.", file=sys.stderr)
  327. voicebot = Voicebot(
  328. args["<channel>"], int(args["--time"]), args["--force-id"],
  329. args["--prefixes"], args["--verbose"],
  330. )
  331. try:
  332. voicebot.start(
  333. args["<host>"], int(args["<port>"]), args["--ssl"],
  334. args["<nickname>"], password, args["--sasl"],
  335. )
  336. finally:
  337. voicebot.save()
  338. if __name__ == "__main__":
  339. main(sys.argv)