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.

394 lines
16KB

  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 argparse
  31. import getpass
  32. import os
  33. import random
  34. import sqlalchemy
  35. import xdg
  36. locale.setlocale(locale.LC_ALL, 'fr_FR.UTF-8')
  37. default_vocabulary = {
  38. 'insults': ['If I had vocabulary, I would insult {nick}.']
  39. }
  40. class KaaBot(sleekxmpp.ClientXMPP):
  41. def __init__(self, jid, password, database, muc, nick, vocabulary_file):
  42. sleekxmpp.ClientXMPP.__init__(self, jid, password)
  43. self.muc = muc
  44. self.nick = nick
  45. self.online_timestamp = None
  46. database_path = self.find_database(database, muc)
  47. self.db = dataset.connect('sqlite:///{db}'.format(db=database_path),
  48. engine_kwargs={'connect_args': {
  49. 'check_same_thread': False}})
  50. self.vocabulary = self.init_vocabulary(vocabulary_file)
  51. self.users = self.db['user']
  52. # Initialize table with correct type.
  53. self.users.create_column('nick', sqlalchemy.String)
  54. self.users.create_column('offline_timestamp', sqlalchemy.DateTime)
  55. self.users.create_column('online_timestamp', sqlalchemy.DateTime)
  56. self.muc_log = self.db['muc_log']
  57. self.add_event_handler("session_start", self.session_start)
  58. self.add_event_handler("message", self.message)
  59. self.add_event_handler("muc::%s::got_online" % self.muc,
  60. self.muc_online)
  61. self.add_event_handler("muc::%s::got_offline" % self.muc,
  62. self.muc_offline)
  63. @staticmethod
  64. def find_database(database, muc):
  65. """Returns the path to the database to use for the given MUC.
  66. If `database` is empty, the file name is generated from the MUC's name
  67. `muc` in the first "kaabot" XDG data dir (usually
  68. `$HOME/.local/share/kaabot/`).
  69. If it contains a value, it is assumed to be the path to the database. If
  70. the name contains the string "{muc}", it will be substituted with the
  71. MUC's name.
  72. The returned path may or may not exist in the file system.
  73. """
  74. if database:
  75. return database.format(muc=muc)
  76. data_dir = xdg.BaseDirectory.save_data_path("kaabot")
  77. database = "{muc}.db".format(muc=muc)
  78. return "{}/{}".format(data_dir, database)
  79. @staticmethod
  80. def init_vocabulary(vocabulary_file):
  81. """Reads the vocabulary from a JSON file.
  82. If vocabulary_file is empty (i.e. the user didn't use the --vocabulary
  83. option), a file named "vocabulary.json" is searched in the first
  84. existing XDG "kaabot" config path, in order of preference (usually
  85. $HOME/.config/kaabot/, then /etc/xdg/kaabot/).
  86. If vocabulary_file contains a value, it is considered to be the path to
  87. a valid vocabulary file.
  88. Error handling:
  89. - If the user-specified file can't be opened, the program will crash.
  90. - Ditto if the XDG-found file exists but can't be opened.
  91. - In case of parsing error (the file exists but is invalid JSON),
  92. minimalistic vocabulary is set.
  93. - Ditto if the user didn't use --vocabulary and no vocabulary file is
  94. found in the XDG config path.
  95. """
  96. if not vocabulary_file:
  97. config_dir = xdg.BaseDirectory.load_first_config("kaabot")
  98. full_path = config_dir + "/vocabulary.json"
  99. if os.path.exists(full_path):
  100. vocabulary_file = full_path
  101. if vocabulary_file:
  102. return KaaBot.read_vocabulary_file(vocabulary_file)
  103. else:
  104. return default_vocabulary
  105. @staticmethod
  106. def read_vocabulary_file(vocabulary_file):
  107. """Actually read and parse the vocabulary file.
  108. """
  109. try:
  110. fd = open(vocabulary_file, encoding='UTF-8')
  111. except OSError:
  112. logging.error("Can't open vocabulary file {filename}!"
  113. .format(filename=vocabulary_file))
  114. raise
  115. try:
  116. vocabulary = json.load(fd)
  117. except ValueError: # json.JSONDecodeError in Python >= 3.5
  118. logging.warning(("Invalid JSON vocabulary file '{filename}'. "
  119. "Minimal vocabulary will be set.")
  120. .format(filename=vocabulary_file))
  121. vocabulary = default_vocabulary
  122. return vocabulary
  123. def session_start(self, event):
  124. self.send_presence()
  125. self.get_roster()
  126. self.plugin['xep_0045'].joinMUC(self.muc, self.nick, wait=True)
  127. self.plugin['xep_0172'].publish_nick(self.nick)
  128. def message(self, msg):
  129. """Handles incoming messages.
  130. """
  131. # Private message
  132. if msg['type'] in ('chat', 'normal'):
  133. # Don't accept private messages unless they are initiated from a MUC
  134. if msg['from'].bare != self.muc:
  135. msg.reply(("Je ne parle pas aux étrangers,"
  136. " cause-moi sur une MUC !")).send()
  137. return
  138. # Message's author info
  139. dest = msg['from']
  140. nick = msg['from'].resource
  141. command = msg['body'].strip()
  142. self.parse_command(command, nick, dest, echo=True)
  143. # Public (MUC) message
  144. elif msg['type'] in ('groupchat'):
  145. # Message's author info
  146. dest = msg['from']
  147. nick = msg['mucnick']
  148. # Insert message in database with timestamp
  149. self.muc_log.insert(dict(datetime=datetime.datetime.now(),
  150. msg=msg['body'], user=nick))
  151. # Stop dealing with this message if we sent it
  152. if msg['mucnick'] == self.nick:
  153. return
  154. splitbody = msg['body'].split(sep=self.nick, maxsplit=1)
  155. # The message starts or ends with the bot's nick
  156. if len(splitbody) == 2:
  157. if splitbody[1]:
  158. # Bot's nick is at the beginning
  159. command = splitbody[1]
  160. else:
  161. # Bot's nick is at the end
  162. command = splitbody[0]
  163. command = command.lstrip('\t :, ').rstrip()
  164. self.parse_command(command, nick, dest)
  165. # The bot's nick was used in the middle of a message
  166. elif self.nick in msg['body']:
  167. self.send_insult(nick, dest.bare)
  168. def parse_command(self, command, nick, dest, echo=False):
  169. """Parses a command sent by dest (nick).
  170. If echo is True, the bot may report publicly information about the
  171. commands processed.
  172. """
  173. if not command: # original message was just the bot's name
  174. self.send_help(dest)
  175. elif command in ['log', 'histo']:
  176. self.send_log(nick, dest, echo)
  177. elif command in ['help', 'aide']:
  178. self.send_help(dest)
  179. elif command in ['uptime']:
  180. self.send_uptime(dest)
  181. else:
  182. self.send_insult(nick, dest.bare)
  183. def send_help(self, dest):
  184. """Sends help messages to 'dest'.
  185. """
  186. intro = ["Il a besoin d'aide le boulet ?"]
  187. cmd = [('(log|histo) : Historique'
  188. 'des messages postés durant ton absence.'),
  189. '(uptime) : Depuis combien de temps je suis debout ?']
  190. mbody = '\n '.join(intro + cmd)
  191. self.send_message(mto=dest,
  192. mbody=mbody,
  193. mtype='chat')
  194. def send_log(self, nick, dest, echo=False):
  195. """Look up backlog for 'nick' and send it to 'dest'.
  196. """
  197. if echo:
  198. gossip = nick + " consulte l'historique en loucedé !"
  199. self.send_message(mto=dest.bare,
  200. mbody=gossip,
  201. mtype='groupchat')
  202. # Get offline timestamp from database and check if it exists.
  203. offline_timestamp = self.users.find_one(nick=nick)['offline_timestamp']
  204. if not offline_timestamp:
  205. logging.debug(('KaaBot : No offline'
  206. ' timestamp for {nick}.').format(nick=nick))
  207. self.send_empty_log(dest)
  208. return
  209. else:
  210. logging.debug(
  211. ('KaaBot : {nick} '
  212. 'last seen on {date}').format(nick=nick,
  213. date=offline_timestamp))
  214. # Get online timestamp from database.
  215. online_timestamp = self.users.find_one(nick=nick)['online_timestamp']
  216. logging.debug(('KaaBot : {nick} last'
  217. ' connection on {date}').format(nick=nick,
  218. date=online_timestamp))
  219. # Since filtered log is a generator we can't know in advance if
  220. # it will be empty. Creating filtered_log_empty allows us to act on
  221. # this event later.
  222. filtered_log_empty = True
  223. filtered_log = (log for log in self.muc_log if
  224. offline_timestamp < log['datetime'] < online_timestamp)
  225. for log in filtered_log:
  226. filtered_log_empty = False
  227. log_message = ': '.join((log['user'], log['msg']))
  228. self.send_message(mto=dest,
  229. mbody=log_message,
  230. mtype='chat')
  231. #  Send message if filtered_log is still empty.
  232. if filtered_log_empty:
  233. logging.debug('KaaBot : Filtered backlog empty.')
  234. self.send_empty_log(dest)
  235. def send_empty_log(self, dest):
  236. """Send message if backlog empty.
  237. """
  238. mbody = "Aucun message depuis ta dernière venue. T'es content ?"
  239. self.send_message(mto=dest,
  240. mbody=mbody,
  241. mtype='chat')
  242. def send_uptime(self, dest):
  243. uptime = str(datetime.datetime.now() - self.online_timestamp)
  244. mbody = "Je suis debout depuis {uptime}".format(uptime=uptime)
  245. self.send_message(mto=dest,
  246. mbody=mbody,
  247. mtype='chat')
  248. def send_insult(self, nick, dest):
  249. insults = self.vocabulary['insults']
  250. i = random.randint(0, len(insults) - 1)
  251. insult = insults[i].format(nick=nick)
  252. self.send_message(mto=dest,
  253. mbody=insult,
  254. mtype='groupchat')
  255. def muc_online(self, presence):
  256. """Handles MUC online presence.
  257. On bot connection gets called for each
  258. user in the MUC (bot included).
  259. """
  260. nick = presence['muc']['nick']
  261. if nick != self.nick:
  262. # Check if nick in database.
  263. if self.users.find_one(nick=nick):
  264. # Update nick online timestamp.
  265. self.users.update(dict(nick=nick,
  266. online_timestamp=datetime.datetime.now()),
  267. ['nick'])
  268. # Check if bot is connecting for the first time.
  269. if self.online_timestamp:
  270. try:
  271. user = self.users.find_one(nick=nick)
  272. offline_timestamp = user['offline_timestamp']
  273. msg = ("Salut {nick}, la dernière fois"
  274. " que j'ai vu ta pomme c'était le {date}.")
  275. msg_formatted = msg.format(nick=nick,
  276. date=datetime.datetime.strftime(
  277. offline_timestamp,
  278. format="%c"))
  279. self.send_message(mto=presence['from'].bare,
  280. mbody=msg_formatted,
  281. mtype='groupchat')
  282. except TypeError:
  283. msg = 'KaaBot : No offline timestamp yet for {nick}'
  284. logging.debug(msg.format(nick=nick))
  285. else:
  286. self.users.insert(dict(nick=nick,
  287. online_timestamp=datetime.datetime.now()))
  288. else:
  289. # Set bot online timestamp.
  290. self.online_timestamp = datetime.datetime.now()
  291. self.send_message(mto=presence['from'].bare,
  292. mbody='/me est dans la place !',
  293. mtype='groupchat')
  294. def muc_offline(self, presence):
  295. """Handles MUC offline presence.
  296. """
  297. nick = presence['muc']['nick']
  298. if nick != self.nick:
  299. self.users.update(dict(nick=nick,
  300. offline_timestamp=datetime.datetime.now()),
  301. ['nick'])
  302. if __name__ == '__main__':
  303. argp = argparse.ArgumentParser(
  304. description="Super Simple Silly Bot for Jabber")
  305. argp.add_argument('-d', '--debug', help="set logging to DEBUG",
  306. action='store_const',
  307. dest='loglevel', const=logging.DEBUG,
  308. default=logging.INFO)
  309. argp.add_argument("-b", "--database", dest="database",
  310. help="path to an alternative database; the '{muc}' string"
  311. " in the name will be substituted with "
  312. "the MUC's name as provided by the --muc option")
  313. argp.add_argument("-j", "--jid", dest="jid", help="JID to use")
  314. argp.add_argument("-p", "--password", dest="password",
  315. help="password to use")
  316. argp.add_argument("-m", "--muc", dest="muc",
  317. help="Multi User Chatroom to join")
  318. argp.add_argument("-n", "--nick", dest="nick", default='KaaBot',
  319. help="nickname to use in the chatroom (default: KaaBot)")
  320. argp.add_argument("-V", "--vocabulary", dest="vocabulary_file",
  321. help="path to an alternative vocabulary file")
  322. args = argp.parse_args()
  323. if args.jid is None:
  324. args.jid = input("Username: ")
  325. if args.password is None:
  326. args.password = getpass.getpass("Password: ")
  327. if args.muc is None:
  328. args.muc = input("MUC: ")
  329. logging.basicConfig(level=args.loglevel,
  330. format='%(levelname)-8s %(message)s')
  331. bot = KaaBot(args.jid, args.password, args.database,
  332. args.muc, args.nick, args.vocabulary_file)
  333. bot.register_plugin('xep_0045')
  334. bot.register_plugin('xep_0071')
  335. bot.register_plugin('xep_0172')
  336. bot.connect()
  337. bot.process(block=True)