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.

kaabot.py 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. #
  4. # Copyright (c) 2016 Bogdan Cordier <ooctogene@gmail.com>
  5. # and Matteo Cypriani <mcy@lm7.fr>
  6. #
  7. # Permission is hereby granted, free of charge, to any person obtaining a copy
  8. # of this software and associated documentation files (the "Software"), to deal
  9. # in the Software without restriction, including without limitation the rights
  10. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11. # copies of the Software, and to permit persons to whom the Software is
  12. # furnished to do so, subject to the following conditions:
  13. #
  14. # The above copyright notice and this permission notice shall be included in all
  15. # copies or substantial portions of the Software.
  16. #
  17. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23. # SOFTWARE.
  24. import json
  25. import logging
  26. import sleekxmpp
  27. import datetime
  28. import locale
  29. import dataset
  30. import configargparse
  31. import getpass
  32. import os
  33. import random
  34. import sqlalchemy
  35. import xdg.BaseDirectory
  36. import pathlib
  37. locale.setlocale(locale.LC_ALL, 'fr_FR.UTF-8')
  38. default_vocabulary = {
  39. 'help': ["My vocabulary empty, I can't help you."],
  40. 'empty_log': ["No log for you."],
  41. 'gossips': ["{nick} is reading the back log."],
  42. 'greetings': ["/me is here!"],
  43. 'insults': ['If I had vocabulary, I would insult {nick}.'],
  44. 'uptime': ["I'm up for {uptime}."],
  45. 'welcome': ["{nick}'s last connection: {date}."],
  46. # Responses to direct messages (not on a MUC):
  47. 'refusals': ["I don't accept direct messages. Try on a MUC."],
  48. }
  49. class KaaBot(sleekxmpp.ClientXMPP):
  50. def __init__(self, jid, password, database, muc, nick, vocabulary_file,
  51. welcome):
  52. sleekxmpp.ClientXMPP.__init__(self, jid, password)
  53. self.muc = muc
  54. self.nick = nick
  55. self.online_timestamp = None
  56. database_path = self.find_database(database, muc)
  57. self.db = dataset.connect('sqlite:///{db}'.format(db=database_path),
  58. engine_kwargs={'connect_args': {
  59. 'check_same_thread': False}})
  60. self.vocabulary = self.init_vocabulary(vocabulary_file)
  61. self.welcome = welcome
  62. self.users = self.db['user']
  63. # Initialize table with correct type.
  64. self.users.create_column('nick', sqlalchemy.String)
  65. self.users.create_column('offline_timestamp', sqlalchemy.DateTime)
  66. self.users.create_column('online_timestamp', sqlalchemy.DateTime)
  67. self.muc_log = self.db['muc_log']
  68. self.add_event_handler("session_start", self.session_start)
  69. self.add_event_handler("message", self.message)
  70. self.add_event_handler("muc::%s::got_online" % self.muc,
  71. self.muc_online)
  72. self.add_event_handler("muc::%s::got_offline" % self.muc,
  73. self.muc_offline)
  74. @staticmethod
  75. def find_database(database, muc):
  76. """Returns the path to the database to use for the given MUC.
  77. If `database` is empty, the file name is generated from the MUC's name
  78. `muc` in the first "kaabot" XDG data dir (usually
  79. `$HOME/.local/share/kaabot/`). If the XDG data dir can't be located, the
  80. database is created/open in the work directory.
  81. If it contains a value, it is assumed to be the path to the database. If
  82. the name contains the string "{muc}", it will be substituted with the
  83. MUC's name.
  84. The returned path may or may not exist in the file system.
  85. """
  86. if database:
  87. return database.format(muc=muc)
  88. data_dir = xdg.BaseDirectory.save_data_path("kaabot")
  89. database = "{muc}.db".format(muc=muc)
  90. return os.path.join(data_dir, database)
  91. @staticmethod
  92. def init_vocabulary(vocabulary_file):
  93. """Reads the vocabulary from a JSON file.
  94. If vocabulary_file is empty (i.e. the user didn't use the --vocabulary
  95. option), a file named "vocabulary.json" is searched in the first
  96. existing XDG "kaabot" config path, in order of preference (usually
  97. $HOME/.config/kaabot/, then /etc/xdg/kaabot/).
  98. If vocabulary_file contains a value, it is considered to be the path to
  99. a valid vocabulary file.
  100. Error handling:
  101. - If the user-specified file can't be opened, the program will crash.
  102. - Ditto if the XDG-found file exists but can't be opened.
  103. - If the XDG directory cannot be detected, "vocabulary.json" is searched
  104. in the work directory.
  105. - In case of parsing error (the file exists but is invalid JSON),
  106. minimalistic vocabulary is set.
  107. - Ditto if the user didn't use --vocabulary and no vocabulary file is
  108. found in the XDG config path.
  109. """
  110. if not vocabulary_file:
  111. config_dir = xdg.BaseDirectory.load_first_config("kaabot")
  112. full_path = os.path.join(config_dir, "vocabulary.json")
  113. if os.path.exists(full_path):
  114. vocabulary_file = full_path
  115. if vocabulary_file:
  116. return KaaBot.read_vocabulary_file(vocabulary_file)
  117. else:
  118. return default_vocabulary
  119. @staticmethod
  120. def read_vocabulary_file(vocabulary_file):
  121. """Actually read and parse the vocabulary file.
  122. """
  123. try:
  124. fd = open(vocabulary_file, encoding='UTF-8')
  125. except IOError:
  126. logging.error("Can't open vocabulary file {filename}!"
  127. .format(filename=vocabulary_file))
  128. raise
  129. try:
  130. vocabulary = json.load(fd)
  131. fd.close()
  132. except ValueError: # json.JSONDecodeError in Python >= 3.5
  133. logging.warning(("Invalid JSON vocabulary file '{filename}'. "
  134. "Minimal vocabulary will be set.")
  135. .format(filename=vocabulary_file))
  136. vocabulary = default_vocabulary
  137. return vocabulary
  138. def session_start(self, event):
  139. self.send_presence()
  140. self.get_roster()
  141. self.plugin['xep_0045'].joinMUC(self.muc, self.nick, wait=True)
  142. self.plugin['xep_0172'].publish_nick(self.nick)
  143. def message(self, msg):
  144. """Handles incoming messages.
  145. """
  146. # Private message
  147. if msg['type'] in ('chat', 'normal'):
  148. # Don't accept private messages unless they are initiated from a MUC
  149. if msg['from'].bare != self.muc:
  150. msg.reply(self.pick_sentence('refusals')).send()
  151. return
  152. # Message's author info
  153. dest = msg['from']
  154. nick = msg['from'].resource
  155. command = msg['body'].strip()
  156. self.parse_command(command, nick, dest, priv=True)
  157. # Public (MUC) message
  158. elif msg['type'] in ('groupchat'):
  159. # Message's author info
  160. dest = msg['from']
  161. nick = msg['mucnick']
  162. # Insert message in database with timestamp
  163. self.muc_log.insert(dict(datetime=datetime.datetime.now(),
  164. msg=msg['body'], user=nick))
  165. # Stop dealing with this message if we sent it
  166. if msg['mucnick'] == self.nick:
  167. return
  168. splitbody = msg['body'].split(sep=self.nick, maxsplit=1)
  169. # The message starts or ends with the bot's nick
  170. if len(splitbody) == 2:
  171. if splitbody[1]:
  172. # Bot's nick is at the beginning
  173. command = splitbody[1]
  174. else:
  175. # Bot's nick is at the end
  176. command = splitbody[0]
  177. command = command.lstrip('\t :, ').rstrip()
  178. self.parse_command(command, nick, dest)
  179. # The bot's nick was used in the middle of a message
  180. elif self.nick in msg['body']:
  181. self.send_insult(nick, dest.bare)
  182. def parse_command(self, command, nick, dest, priv=False):
  183. """Parses a command sent by dest (nick).
  184. `priv` should be True if the bot was contacted through a private
  185. message, False if analysing a public message. In any case, the bot may
  186. report publicly information about the commands processed.
  187. """
  188. if not command: # original message was just the bot's name
  189. self.send_help(dest)
  190. elif command in ['log', 'histo']:
  191. self.send_log(nick, dest, echo=priv)
  192. elif command in ['help', 'aide']:
  193. self.send_help(dest)
  194. elif command in ['uptime']:
  195. self.send_uptime(dest, priv)
  196. else:
  197. self.send_insult(nick, dest.bare)
  198. def send_help(self, dest):
  199. """Sends help messages to 'dest'.
  200. """
  201. mbody = '\n '.join(self.vocabulary['help'])
  202. self.send_message(mto=dest,
  203. mbody=mbody,
  204. mtype='chat')
  205. def send_log(self, nick, dest, echo=False):
  206. """Look up backlog for 'nick' and send it to 'dest'.
  207. """
  208. if echo:
  209. gossip = self.pick_sentence('gossips').format(nick=nick)
  210. self.send_message(mto=dest.bare,
  211. mbody=gossip,
  212. mtype='groupchat')
  213. # Get offline timestamp from database and check if it exists.
  214. offline_timestamp = self.users.find_one(nick=nick)['offline_timestamp']
  215. if not offline_timestamp:
  216. logging.debug(('KaaBot : No offline'
  217. ' timestamp for {nick}.').format(nick=nick))
  218. self.send_empty_log(dest)
  219. return
  220. else:
  221. logging.debug(
  222. ('KaaBot : {nick} '
  223. 'last seen on {date}').format(nick=nick,
  224. date=offline_timestamp))
  225. # Get online timestamp from database.
  226. online_timestamp = self.users.find_one(nick=nick)['online_timestamp']
  227. logging.debug(('KaaBot : {nick} last'
  228. ' connection on {date}').format(nick=nick,
  229. date=online_timestamp))
  230. # Since filtered log is a generator we can't know in advance if
  231. # it will be empty. Creating filtered_log_empty allows us to act on
  232. # this event later.
  233. filtered_log_empty = True
  234. filtered_log = (log for log in self.muc_log if
  235. offline_timestamp < log['datetime'] < online_timestamp)
  236. for log in filtered_log:
  237. filtered_log_empty = False
  238. log_message = "[{:%H:%M}] {}: {}".format(log['datetime'],
  239. log['user'],
  240. log['msg'])
  241. self.send_message(mto=dest,
  242. mbody=log_message,
  243. mtype='chat')
  244. # Send message if filtered_log is still empty.
  245. if filtered_log_empty:
  246. logging.debug('KaaBot : Filtered backlog empty.')
  247. self.send_empty_log(dest)
  248. def send_empty_log(self, dest):
  249. """Send message if backlog empty.
  250. """
  251. mbody = self.pick_sentence('empty_log')
  252. self.send_message(mto=dest,
  253. mbody=mbody,
  254. mtype='chat')
  255. def send_uptime(self, dest, priv=False):
  256. """Sends the uptime to `dest`.
  257. If `priv` is true, the message is sent privately, otherwise it's send on
  258. the MUC.
  259. """
  260. uptime = str(datetime.datetime.now() - self.online_timestamp)
  261. mbody = self.pick_sentence('uptime').format(uptime=uptime)
  262. if priv:
  263. self.send_message(mto=dest,
  264. mbody=mbody,
  265. mtype='chat')
  266. else:
  267. self.send_message(mto=dest.bare,
  268. mbody=mbody,
  269. mtype='groupchat')
  270. def send_insult(self, nick, dest):
  271. """Sends an insult about `nick` to `dest`.
  272. """
  273. insult = self.pick_sentence('insults').format(nick=nick)
  274. self.send_message(mto=dest,
  275. mbody=insult,
  276. mtype='groupchat')
  277. def send_welcome(self, nick, dest, date):
  278. msg = self.pick_sentence('welcome').format(nick=nick, date=date)
  279. self.send_message(mto=dest,
  280. mbody=msg,
  281. mtype='groupchat')
  282. def pick_sentence(self, type):
  283. """Returns a random sentence picked in the loaded vocabulary.
  284. `type` can be any known category of the vocabulary file, e.g. 'insults'.
  285. No substitution is done to the returned string.
  286. """
  287. voc = self.vocabulary[type]
  288. i = random.randint(0, len(voc) - 1)
  289. return voc[i]
  290. def muc_online(self, presence):
  291. """Handles MUC online presence.
  292. On bot connection gets called for each
  293. user in the MUC (bot included).
  294. """
  295. nick = presence['muc']['nick']
  296. if nick != self.nick:
  297. # Check if nick in database.
  298. if self.users.find_one(nick=nick):
  299. # Update nick online timestamp.
  300. self.users.update(dict(nick=nick,
  301. online_timestamp=datetime.datetime.now()),
  302. ['nick'])
  303. # Check if bot is connecting for the first time.
  304. if self.online_timestamp:
  305. try:
  306. user = self.users.find_one(nick=nick)
  307. offline_timestamp = user['offline_timestamp']
  308. date = datetime.datetime.strftime(offline_timestamp,
  309. format="%c")
  310. logging.debug('KaaBot : user {} connected, last seen {}'
  311. .format(nick, date))
  312. if self.welcome:
  313. dest = presence['from'].bare
  314. self.send_welcome(nick, dest, date)
  315. except TypeError:
  316. msg = 'KaaBot : No offline timestamp yet for {nick}'
  317. logging.debug(msg.format(nick=nick))
  318. else:
  319. self.users.insert(dict(nick=nick,
  320. online_timestamp=datetime.datetime.now()))
  321. else:
  322. # Set bot online timestamp.
  323. self.online_timestamp = datetime.datetime.now()
  324. self.send_message(mto=presence['from'].bare,
  325. mbody=self.pick_sentence('greetings'),
  326. mtype='groupchat')
  327. def muc_offline(self, presence):
  328. """Handles MUC offline presence.
  329. """
  330. nick = presence['muc']['nick']
  331. if nick != self.nick:
  332. self.users.update(dict(nick=nick,
  333. offline_timestamp=datetime.datetime.now()),
  334. ['nick'])
  335. def str_to_bool(text):
  336. """Converts a string to a boolean.
  337. Raises an exception if the string does not describe a boolean value.
  338. """
  339. text = text.lower()
  340. if text in ["on", "true", "1"]:
  341. return True
  342. elif text in ["off", "false", "0"]:
  343. return False
  344. raise TypeError
  345. if __name__ == '__main__':
  346. config_dir = xdg.BaseDirectory.save_config_path("kaabot")
  347. config_file = os.path.join(config_dir, 'config')
  348. argp = configargparse.ArgParser(default_config_files=[config_file],
  349. description="Super Simple Silly Bot for Jabber.")
  350. argp.add_argument('-d', '--debug', help="set logging to DEBUG",
  351. action='store_const',
  352. dest='debug', const=logging.DEBUG,
  353. default=logging.INFO)
  354. argp.add_argument("-b", "--database", dest="database",
  355. help="path to an alternative database; the '{muc}' string"
  356. " in the name will be substituted with "
  357. "the MUC's name as provided by the --muc option")
  358. argp.add_argument("-j", "--jid", dest="jid", help="JID to use")
  359. argp.add_argument("-p", "--password", dest="password",
  360. help="password to use")
  361. argp.add_argument("-m", "--muc", dest="muc",
  362. help="Multi User Chatroom to join")
  363. argp.add_argument("-n", "--nick", dest="nick", default='KaaBot',
  364. help="nickname to use in the chatroom (default: KaaBot)")
  365. argp.add_argument("-V", "--vocabulary_file", dest="vocabulary_file",
  366. help="path to an alternative vocabulary file")
  367. argp.add_argument("--welcome", dest="welcome", default="on",
  368. type=str_to_bool,
  369. help="welcome users joining the MUC (on/off, default: on)")
  370. args = argp.parse_args()
  371. if args.jid is None:
  372. args.jid = input("Username: ")
  373. if args.password is None:
  374. args.password = getpass.getpass("Password: ")
  375. if args.muc is None:
  376. args.muc = input("MUC: ")
  377. logging.basicConfig(level=args.debug,
  378. format='%(levelname)-8s %(message)s')
  379. try:
  380. pathlib.Path(config_file).touch(mode=0o600, exist_ok=False)
  381. arguments = vars(args)
  382. arguments.pop("debug")
  383. with open(config_file, 'w') as f:
  384. f.writelines('{}= {}\n'.format(k, v) for k, v
  385. in arguments.items() if v)
  386. except FileExistsError:
  387. logging.debug('Config file exists.')
  388. bot = KaaBot(args.jid, args.password, args.database,
  389. args.muc, args.nick, args.vocabulary_file,
  390. args.welcome)
  391. bot.register_plugin('xep_0045')
  392. bot.register_plugin('xep_0071')
  393. bot.register_plugin('xep_0172')
  394. bot.connect()
  395. bot.process(block=True)