libtn.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. '''
  2. TwitchTV, Notify and config reading abstractions for TwitchNotifier
  3. '''
  4. import configparser
  5. import time
  6. import re
  7. import sys
  8. import os
  9. import requests
  10. import gi
  11. gi.require_version('Notify', '0.7')
  12. from gi.repository import Notify
  13. BASE_URL = 'https://api.twitch.tv/kraken'
  14. CLIENT_ID = 'pvv7ytxj4v7i10h0p3s7ewf4vpoz5fc'
  15. HEAD = {'Accept': 'application/vnd.twitch.v3+json',
  16. 'Client-ID': CLIENT_ID}
  17. LIMIT = 100
  18. SECTION = 'messages'
  19. class Settings(object):
  20. '''
  21. Saves the user configuration and can parse it from files or environment
  22. variables.
  23. '''
  24. cfg = ''
  25. user_message = {'on': '$1 is $2', 'off': '$1 is $2'}
  26. notification_title = {'on': '$1', 'off': '$1'}
  27. notification_cont = {'on': 'is $2', 'off': 'is $2'}
  28. list_entry = {'on': '$1', 'off': '$1'}
  29. log_fmt = {'on': '(${%d %H:%M:%S}) $1 is $2', 'off': '(${%d %H:%M:%S}) $1 is $2'}
  30. def __init__(self, cfg):
  31. '''
  32. Initialize the object and parse cfg and environment variables to get the
  33. configuration
  34. Positional arguments:
  35. cfg - full path to the configuration file
  36. Raises:
  37. ValueError - cfg is empty
  38. '''
  39. if not cfg.strip():
  40. raise ValueError('Empty string passed to Settings')
  41. self.cfg = cfg
  42. self.conf = configparser.ConfigParser()
  43. self.read_file()
  44. self.environment()
  45. def environment(self):
  46. '''
  47. Parse user settings from the environment variables
  48. '''
  49. self.user_message['on'] = os.getenv('user_message', self.user_message['on'])
  50. self.user_message['off'] = os.getenv('user_message_off',
  51. self.user_message['off'])
  52. self.notification_title['on'] = os.getenv('notification_title',
  53. self.notification_title['on'])
  54. self.notification_title['off'] = os.getenv('notification_title_off',
  55. self.notification_title['off'])
  56. self.notification_cont['on'] = os.getenv('notification_content',
  57. self.notification_cont['on'])
  58. self.notification_cont['off'] = os.getenv('notification_content_off',
  59. self.notification_cont['off'])
  60. self.list_entry['on'] = os.getenv('list_entry', self.list_entry['on'])
  61. self.list_entry['off'] = os.getenv('list_entry_off', self.list_entry['off'])
  62. self.log_fmt['on'] = os.getenv('log_fmt', self.log_fmt['on'])
  63. self.log_fmt['off'] = os.getenv('log_fmt_off', self.log_fmt['off'])
  64. def read_file(self):
  65. '''
  66. Read self.cfg and parse user configuration from that file
  67. '''
  68. try:
  69. self.conf.read(self.cfg)
  70. except configparser.MissingSectionHeaderError:
  71. return
  72. if SECTION not in self.conf:
  73. print('Missing section "' + SECTION + '" in ' + self.cfg,
  74. file=sys.stderr)
  75. return
  76. opt = self.conf[SECTION]
  77. self.user_message['on'] = opt.get('user_message', self.user_message['on'],
  78. raw=True)
  79. self.user_message['off'] = opt.get('user_message_off',
  80. self.user_message['off'],
  81. raw=True)
  82. self.notification_title['on'] = opt.get('notification_title',
  83. self.notification_title['on'],
  84. raw=True)
  85. self.notification_title['off'] = opt.get('notification_title_off',
  86. self.notification_title['off'],
  87. raw=True)
  88. self.notification_cont['on'] = opt.get('notification_content',
  89. self.notification_cont['on'],
  90. raw=True)
  91. self.notification_cont['off'] = opt.get('notification_content_off',
  92. self.notification_cont['off'],
  93. raw=True)
  94. self.list_entry['on'] = opt.get('list_entry', self.list_entry['on'], raw=True)
  95. self.list_entry['off'] = opt.get('list_entry_off',
  96. self.list_entry['off'],
  97. raw=True)
  98. self.log_fmt['on'] = opt.get('log_fmt', self.log_fmt['on'], raw=True)
  99. self.log_fmt['off'] = opt.get('log_fmt_off', self.log_fmt['off'],
  100. raw=True)
  101. class NotifyApi(object):
  102. '''
  103. A wrapper around calls to the TTV API
  104. '''
  105. nick = ''
  106. verbose = False
  107. fhand = None
  108. statuses = {}
  109. def __init__(self, nick, fmt, logfile, verbose=False):
  110. '''
  111. Initialize the API with various options
  112. Positional arguments:
  113. nick - nickname of the user
  114. fmt - a Settings object
  115. logfile - location of the log file
  116. verbose - if we should be verbose in output
  117. '''
  118. self.nick = nick
  119. self.verbose = verbose
  120. self.fmt = fmt
  121. if logfile is not None:
  122. self.fhand = open(logfile, 'a')
  123. def get_followed_channels(self, payload=None):
  124. '''
  125. Get a list of channels the user is following
  126. Positional arguments:
  127. payload - a dict that will be converted to args which will be passed in
  128. a GET request
  129. Raises:
  130. NameError - when the current nickname is invalid
  131. Returns a list of channels that user follows
  132. '''
  133. ret = []
  134. cmd = '/users/' + self.nick + '/follows/channels'
  135. if payload is None:
  136. payload = {}
  137. json = self.access_kraken(cmd, payload)
  138. if json is None:
  139. return ret
  140. if 'status' in json and json['status'] == 404:
  141. raise NameError(self.nick + ' is a invalid nickname!')
  142. if 'follows' in json:
  143. for chan in json['follows']:
  144. ret.append(chan['channel']['name'])
  145. return ret
  146. def __del__(self):
  147. '''Clean up everything'''
  148. Notify.uninit()
  149. if self.fhand is not None:
  150. self.fhand.close()
  151. def access_kraken(self, cmd, payload=None):
  152. '''
  153. Generic wrapper around kraken calls
  154. Positional arguments:
  155. cmd - command such as '/streams'
  156. payload - dict of arguments to send with the request
  157. Returns:
  158. None - error occured
  159. Otherwise, json response
  160. '''
  161. url = BASE_URL + cmd
  162. if payload is None:
  163. payload = {}
  164. try:
  165. req = requests.get(url, headers=HEAD, params=payload)
  166. except requests.exceptions.RequestException as ex:
  167. print('Exception in access_kraken::requests.get()',
  168. '__doc__ = ' + str(ex.__doc__), file=sys.stderr, sep='\n')
  169. return None
  170. if self.verbose:
  171. print('-'*20, file=sys.stderr)
  172. print('cmd: ' + cmd, 'payload: ' + str(payload), file=sys.stderr, sep='\n')
  173. print('req.text: ' + req.text, 'req.status_code: ' +
  174. str(req.status_code), 'req.headers: ' + str(req.headers),
  175. file=sys.stderr, sep='\n')
  176. print('-'*20, file=sys.stderr)
  177. if req.status_code == requests.codes.bad:
  178. print('Kraken request returned bad code, bailing', file=sys.stderr)
  179. return None
  180. try:
  181. json = req.json()
  182. except ValueError:
  183. print('Failed to parse json in access_kraken',
  184. file=sys.stderr)
  185. return None
  186. return json
  187. def check_if_online(self, chan):
  188. '''
  189. Check the online status of channels in a list and get formatted messages
  190. Positional arguments:
  191. chan - list of channel names
  192. Returns a dictionary of tuples of format (status, formatted_msg)
  193. '''
  194. ret = {}
  195. i = 0
  196. if len(chan) == 0:
  197. if self.verbose:
  198. print('channel passed to check_if_online is empty',
  199. file=sys.stderr)
  200. return ret
  201. cont = True
  202. while cont:
  203. payload = {'channel': ','.join(chan[i*LIMIT:(i+1)*LIMIT]), 'limit': LIMIT,
  204. 'offset': 0}
  205. resp = self.access_kraken('/streams', payload)
  206. if resp is None or 'streams' not in resp:
  207. break
  208. for stream in resp['streams']:
  209. name = stream['channel']['name']
  210. ret[name] = (True, repl(stream, name, self.fmt.user_message['on']))
  211. i += 1
  212. cont = i*LIMIT < len(chan)
  213. for name in chan:
  214. if name not in ret:
  215. ret[name] = (False, repl(None, name, self.fmt.user_message['off']))
  216. return ret
  217. def get_status(self):
  218. '''
  219. Get a list of dictionaries in format of {'name': (True/False/None,
  220. stream_obj)} of self.nick followed channels
  221. True = channel is online, False = channel is offline, None = error
  222. '''
  223. followed_chans = []
  224. ret = {}
  225. offset = 0
  226. i = 0
  227. while True:
  228. fol = self.get_followed_channels({'offset': offset,
  229. 'limit': LIMIT})
  230. for chan in fol:
  231. followed_chans.append(chan)
  232. if len(fol) == 0:
  233. break
  234. offset = offset + LIMIT
  235. if len(followed_chans) == 0:
  236. return ret
  237. cmd = '/streams'
  238. while True:
  239. payload = {'channel': ','.join(followed_chans[i*LIMIT:(i+1)*LIMIT]),
  240. 'offset': 0, 'limit': LIMIT}
  241. json = self.access_kraken(cmd, payload)
  242. if json and 'streams' in json:
  243. for stream in json['streams']:
  244. ret[stream['channel']['name']] = (True, stream)
  245. i += 1
  246. if i*LIMIT > len(followed_chans):
  247. break
  248. for name in followed_chans:
  249. if name not in ret:
  250. ret[name] = (False, None)
  251. return ret
  252. def inform_user(self, online, data, name):
  253. '''
  254. Actually inform the user about the change in status.
  255. Positional arguments:
  256. online - is the user `name' online now or not
  257. data - information about the user from self.get_status()
  258. name - actual name of the user we are talking about
  259. '''
  260. if online is True:
  261. title = repl(data[1], name, self.fmt.notification_title['on'])
  262. message = repl(data[1], name, self.fmt.notification_cont['on'])
  263. self.log(data[1], name, self.fmt.log_fmt['on'])
  264. else:
  265. title = repl(data[1], name, self.fmt.notification_title['off'])
  266. message = repl(data[1], name, self.fmt.notification_cont['off'])
  267. self.log(data[1], name, self.fmt.log_fmt['off'])
  268. try:
  269. show_notification(title, message)
  270. except RuntimeError:
  271. print('Failed to show a notification:',
  272. file=sys.stderr)
  273. print('Title: ' + title, file=sys.stderr)
  274. print('Message: ' + message, file=sys.stderr)
  275. def diff(self, new):
  276. '''
  277. Check if there is a difference between statuses in `new' and the
  278. dictionary inside the class and if there is then notify the user about
  279. the change
  280. Positional arguments:
  281. new - dictionary returned from get_status()
  282. '''
  283. for name, data in new.items():
  284. ison = data[0]
  285. if ison is None:
  286. continue
  287. if name not in self.statuses:
  288. self.statuses[name] = ison
  289. continue
  290. if ison == self.statuses[name]:
  291. continue
  292. if ison is True and not self.statuses[name] is True:
  293. self.inform_user(True, data, name)
  294. elif self.statuses[name] is True and not ison is True:
  295. self.inform_user(False, data, name)
  296. self.statuses[name] = ison
  297. Notify.uninit()
  298. def log(self, stream, chan, msg):
  299. '''
  300. Write formatted msg to self.fl if it's open
  301. Positional arguments:
  302. stream - stream object
  303. chan - channel name
  304. msg - a format string
  305. '''
  306. if self.fhand is None:
  307. return
  308. self.fhand.write(repl(stream, chan, msg) + '\n')
  309. self.fhand.flush()
  310. def repl(stream, chan, msg):
  311. '''
  312. Format msg according to the stream object
  313. Note that only $1 and $2 will be replaced if stream is offline
  314. Keys:
  315. $1 - streamer username
  316. $2 - offline/online
  317. $3 - game
  318. $4 - viewers
  319. $5 - status
  320. $6 - language
  321. $7 - average FPS
  322. $8 - followers
  323. $9 - views
  324. ${} - everything between {} will be replaced as if strftime is applied
  325. Positional arguments:
  326. stream - stream object (a dictionary with certain values)
  327. chan - channel name
  328. msg - a format string
  329. Returns msg formatted
  330. '''
  331. ret = msg
  332. ret = ret.replace('$2', 'online' if stream else 'offline')
  333. ret = ret.replace('$1', chan)
  334. ret = re.sub(r'\$\{(.*)\}', lambda x: time.strftime(x.group(1)), ret)
  335. if stream is not None:
  336. ret = ret.replace('$3', str(stream.get('game', '')))
  337. ret = ret.replace('$4', str(stream.get('viewers', '')))
  338. ret = ret.replace('$5', stream.get('channel', {}).get('status',
  339. ''))
  340. ret = ret.replace('$6', stream.get('channel', {}).get('language',
  341. ''))
  342. ret = ret.replace('$7', str(stream.get('average_fps', '')))
  343. ret = ret.replace('$8', str(stream.get('channel',
  344. {}).get('followers', '')))
  345. ret = ret.replace('$9', str(stream.get('channel', {}).get('views',
  346. '')))
  347. return ret
  348. def show_notification(title, message):
  349. '''
  350. Show a notification using libnotify/gobject
  351. Positional arguments:
  352. title - notification title
  353. message - notification message
  354. Raises:
  355. RuntimeError - failed to show the notification
  356. Note:
  357. This function is designed to be called a few times in a row so
  358. make sure to call Notify.uninit() afterwards
  359. '''
  360. if Notify.is_initted() is False:
  361. Notify.init('TwitchNotifier')
  362. if Notify.is_initted() is False:
  363. raise RuntimeError('Failed to init notify')
  364. notif = Notify.Notification.new(title, message)
  365. if not notif.show():
  366. raise RuntimeError('Failed to show a notification')