main.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931
  1. #!/usr/bin/env python3
  2. import argparse
  3. import configparser
  4. import daemon
  5. import getpass
  6. import json
  7. import logging
  8. import os, os.path
  9. import re
  10. import signal
  11. import sys
  12. import time
  13. import bs4
  14. import crontab
  15. import mastodon
  16. import requests
  17. import custom
  18. from pprint import pprint
  19. # =============================================================================
  20. # CONSTANTS
  21. # =============================================================================
  22. BOT_VERSION_NUMBER = "dev"
  23. BOT_VERSION = "Boostbot - v" + BOT_VERSION_NUMBER
  24. BOT_APPNAME = "Boostbot"
  25. BOT_DATA_DEFAULT = { "client_id" : None,
  26. "client_secret" : None,
  27. "login" : None,
  28. "access_token" : None,
  29. "username" : None,
  30. "instance" : None,
  31. "instance_url" : None,
  32. "master_username" : None,
  33. "master_instance" : None,
  34. "users_blacklist" : [],
  35. "sources" : { "timeline" : {},
  36. "hashtag" : {},
  37. "mentions" : None } }
  38. # =============================================================================
  39. # ARGUMENTS
  40. # =============================================================================
  41. parser = argparse.ArgumentParser()
  42. parser.add_argument ( "-V", "--version",
  43. action = "store_true",
  44. help = "show bot version" )
  45. parser.add_argument ( "-D", "--debug",
  46. action = "store_true",
  47. help = "activate debug mode" )
  48. parser.add_argument ( "-s", "--simulate",
  49. action = "store_true",
  50. help = "do not actually post anything or save anything" )
  51. parser.add_argument ( "-F", "--foreground",
  52. action = "store_true",
  53. help = "run in foreground" )
  54. parser.add_argument ( "-v", "--verbose",
  55. action = "count",
  56. default = 0,
  57. help = "print more verbose logs; repeat to increase verbosity" )
  58. parser.add_argument ( "-f", "--config-file",
  59. default = "/etc/mastodon/boostbot.conf",
  60. help = "indicates the main config file to use" )
  61. parser.add_argument ( "-d", "--data-file",
  62. default = "/var/run/mastodon/boostbot.data",
  63. help = "indicates the data file to use" )
  64. args = parser.parse_args()
  65. # =============================================================================
  66. # LOGGING
  67. # =============================================================================
  68. log = logging.getLogger(BOT_APPNAME)
  69. log.setLevel(logging.DEBUG)
  70. log_handler_console = logging.StreamHandler()
  71. if args.verbose > 1:
  72. log_handler_console.setLevel(logging.DEBUG)
  73. elif args.verbose > 0:
  74. log_handler_console.setLevel(logging.INFO)
  75. else:
  76. log_handler_console.setLevel(logging.WARNING)
  77. try:
  78. import pycolog
  79. log_formatter_console = pycolog.ColoredFormatter("%(messages)s")
  80. except ImportError:
  81. log_formatter_console = logging.Formatter("%(name)s:%(levelname)s: %(message)s")
  82. log_handler_console.setFormatter(log_formatter_console)
  83. log.addHandler(log_handler_console)
  84. # =============================================================================
  85. #FUNCTIONS
  86. # =============================================================================
  87. # -----------------------------------------------------------------------------
  88. def load_data(datafile):
  89. logger = logging.getLogger(BOT_APPNAME)
  90. data = {}
  91. try:
  92. logger.debug("Loading data from {}".format(datafile))
  93. with open(datafile, 'r') as file:
  94. data = json.load(file)
  95. except FileNotFoundError:
  96. logger.warning("No data file {} found".format(datafile))
  97. # Set default values if they do not exist
  98. for d in BOT_DATA_DEFAULT:
  99. if not d in data:
  100. logger.warning("'{}' not in data, adding default value".format(d))
  101. data[d] = BOT_DATA_DEFAULT[d]
  102. return data
  103. # -----------------------------------------------------------------------------
  104. def save_data(data, datafilename):
  105. logger = logging.getLogger(BOT_APPNAME)
  106. logger.debug("Saving data to '{}'".format(datafilename))
  107. with open(datafilename,'w') as file:
  108. json.dump(data,file)
  109. # -----------------------------------------------------------------------------
  110. def mark_toot_as_treated(mastodon, toot, interacted_with=False):
  111. if interacted_with:
  112. mastodon.status_favourite(toot["id"])
  113. # -----------------------------------------------------------------------------
  114. def is_toot_treated(mastodon, toot):
  115. return toot["favourited"]
  116. # =============================================================================
  117. # CLASSES
  118. # =============================================================================
  119. # -----------------------------------------------------------------------------
  120. class Rule:
  121. # -------------------------------------------------------------------------
  122. def __init__(self, name, config_section):
  123. self.name = name
  124. self.action = config_section.get("action", fallback=None)
  125. self.action_info = config_section.get("action_info", fallback=None)
  126. self.debug = config_section.getboolean("debug", fallback=False)
  127. # -------------------------------------------------------------------------
  128. def __str__(self):
  129. return self.name
  130. # -------------------------------------------------------------------------
  131. def __repr__(self):
  132. return self.name
  133. # -------------------------------------------------------------------------
  134. def act(self, mastodon, context, data, toot=None, really_act=True):
  135. logger = logging.getLogger(BOT_APPNAME)
  136. logger.info("Acting according to rule '{}':" \
  137. .format(self.name) )
  138. # FOLLOW ACTION
  139. if self.action == "follow":
  140. logger.info( "Following @{}" \
  141. .format( toot["account"]["acct"] ) )
  142. if not really_act:
  143. return
  144. mastodon.account_follow(toot["account"]["id"])
  145. return
  146. # UNFOLLOW ACTION
  147. if self.action == "unfollow":
  148. logger.info( "Unfollowing @{}"\
  149. .format( toot["account"]["acct"] ) )
  150. if not really_act:
  151. return
  152. mastodon.account_unfollow(toot["account"]["id"])
  153. return
  154. # BOOST ACTION
  155. if self.action == "boost":
  156. logger.info( "Boosting toot n°{} by @{}" \
  157. .format( toot["id"],
  158. toot["account"]["acct"] ) )
  159. if not really_act:
  160. return
  161. mastodon.status_reblog(toot["id"])
  162. return
  163. # For the next actions, add toot author to context
  164. try:
  165. context["author"] = toot["account"]["acct"]
  166. except:
  167. pass
  168. # ANSWER ACTION
  169. if self.action == "answer":
  170. status = "@{} " \
  171. .format(context["author"]) \
  172. + self.action_info \
  173. .format(**context)
  174. logger.info( "Answering toot n°{} by @{}" \
  175. .format( toot["id"],
  176. toot["account"]["acct"] ) )
  177. if not really_act:
  178. return
  179. mastodon.status_post( status = status,
  180. in_reply_to_id = toot["id"] )
  181. return
  182. # TOOT ACTION
  183. if self.action == "toot":
  184. status = self.action_info \
  185. .format(**context)
  186. logger.info( "Tooting '{}'" \
  187. .format( status ) )
  188. if not really_act:
  189. return
  190. mastodon.status_post( status = status )
  191. return
  192. # BLACKLIST ACTION
  193. if self.action == "blacklist":
  194. author = toot["account"]["acct"]
  195. logger.info( "Blacklisting @{}" \
  196. .format( author ) )
  197. if not really_act:
  198. return
  199. status = "@{} ".format(author)
  200. if not author in data["users_blacklist"]:
  201. data["users_blacklist"].append(author)
  202. status += "Blacklisted."
  203. else:
  204. status += "Already blacklisted."
  205. mastodon.status_post( status = status,
  206. in_reply_to_id = toot["id"] )
  207. return
  208. # WHITELIST ACTION
  209. if self.action == "whitelist":
  210. author = toot["account"]["acct"]
  211. logger.info( "Whitelisting @{}" \
  212. .format( author ) )
  213. if not really_act:
  214. return
  215. status = "@{} ".format(author)
  216. if not author in data["users_blacklist"]:
  217. status += "Already not blacklisted."
  218. else:
  219. data["users_blacklist"].remove(author)
  220. status += "Unblacklisted."
  221. mastodon.status_post( status = status )
  222. return
  223. # CUSTOM ACTION
  224. if self.action == "custom":
  225. logger.info( "Running custom action '{}'" \
  226. .format( self.action_info ) )
  227. if not really_act:
  228. return
  229. function = getattr(custom, self.action_info)
  230. return function(mastodon, context, toot)
  231. # -----------------------------------------------------------------------------
  232. class ReactRule(Rule):
  233. """A Rule for a reaction of the bot: an action is triggered if the rule
  234. matches a toot from a monitored source."""
  235. # -------------------------------------------------------------------------
  236. def __init__(self, name, config_section):
  237. super(ReactRule, self).__init__(name, config_section)
  238. #
  239. self.is_answered = config_section. \
  240. getboolean( "answered",
  241. fallback=False )
  242. #
  243. self.is_addressed = config_section. \
  244. getboolean( "addressed",
  245. fallback=False )
  246. #
  247. self.is_mentionned = config_section. \
  248. getboolean( "mentionned",
  249. fallback=self.is_addressed )
  250. # Compile this rule pattern
  251. pattern = config_section.get("pattern", fallback="")
  252. ignore_case = config_section.getboolean("ignore_case", fallback=True)
  253. flags = 0
  254. if ignore_case:
  255. flags = flags | re.IGNORECASE
  256. #
  257. self.pattern = re.compile( pattern, flags )
  258. # -------------------------------------------------------------------------
  259. def matches(self, mastodon, context, data, toot, really_act=True):
  260. logger = logging.getLogger(BOT_APPNAME)
  261. if self.debug:
  262. logger.debug("Matching reaction rule {}".format(self.name))
  263. # Check that we are not talking to ourselves, which would make for some
  264. # funny screenshots, but a useless bot.
  265. if toot["account"]["id"] == context["bot"]["id"]:
  266. if self.debug:
  267. logger.debug("Stop before talking to ourselves")
  268. return False
  269. # Check the reply if the rule must matched addressed
  270. if self.is_answered:
  271. if toot["in_reply_to_account_id"] != context["bot"]["id"]:
  272. if self.debug:
  273. logger.debug("This toot is not in answer to us")
  274. return False
  275. # Check the mentions if the rule must match mentionned
  276. if self.is_mentionned:
  277. accounts_mentions = map( lambda m: m["acct"], toot["mentions"] )
  278. if not context["bot"]["acct"] in accounts_mentions:
  279. if self.debug:
  280. logger.debug("We are not mentionned in this toot")
  281. return False
  282. # Filter out if the author is blacklisted and we are not mentionned
  283. if ( not self.is_mentionned
  284. and toot["account"]["acct"] in data["users_blacklist"] ):
  285. if self.debug:
  286. logger.debug("Author is blacklisted")
  287. return False
  288. # Parse the text
  289. text = bs4.BeautifulSoup( toot["content"], "html.parser").get_text()
  290. # Check if it's addressed to us, meaning we are the first mention
  291. if self.is_addressed:
  292. if not text.startswith("@{}".format(context["bot"]["username"])):
  293. if self.debug:
  294. logger.debug("This toot is not adressed to us")
  295. return False
  296. # Check if the rule matches the pattern. We use bs4 to parse html and
  297. # match only content
  298. match = self.pattern.search( text )
  299. if match is None :
  300. if self.debug:
  301. logger.debug("No match in text".format(text))
  302. return False
  303. toot["content_text"] = text
  304. self.act( mastodon, context, data, toot, really_act )
  305. return True
  306. # -----------------------------------------------------------------------------
  307. class ActRule(Rule):
  308. """A Rule for an autonomous action of the bot: the action is triggered at a
  309. specific time."""
  310. def __init__(self, name, config_section):
  311. super(ActRule, self).__init__(name, config_section)
  312. #
  313. self.date = crontab.CronTab( config_section.get("at") )
  314. # -------------------------------------------------------------------------
  315. def triggers( self, mastodon, context, data, sleep=30, really_act=True):
  316. if (self.date.next(default_utc=True) - sleep) > 0:
  317. return False
  318. self.act( mastodon, context, data, really_act=really_act )
  319. return True
  320. # -----------------------------------------------------------------------------
  321. class Source:
  322. TYPE_HASHTAG = "hashtag"
  323. TYPE_TIMELINE = "timeline"
  324. TYPE_MENTIONS = "mentions"
  325. # -------------------------------------------------------------------------
  326. def __init__(self, name, type):
  327. if type == self.TYPE_HASHTAG:
  328. if name[0] == "#":
  329. self.name = name[1:]
  330. else:
  331. self.name = name
  332. elif type == self.TYPE_TIMELINE:
  333. if name in ["local", "instance"]:
  334. self.name = "local"
  335. elif name in ["public", "federated", "federation", "world"]:
  336. self.name = "public"
  337. elif name in ["home", "mine", "personal"]:
  338. self.name = "home"
  339. else:
  340. raise ValueError("Unknown timeline name: {}".format(name))
  341. elif type == self.TYPE_MENTIONS:
  342. name = self.TYPE_MENTIONS
  343. else:
  344. raise ValueError("Unknown source type: {}".format(type))
  345. self.last_id = 0
  346. # -------------------------------------------------------------------------
  347. def __str__(self):
  348. return self.name
  349. # -------------------------------------------------------------------------
  350. def __repr__(self):
  351. return self.name
  352. # -----------------------------------------------------------------------------
  353. class TootQueue:
  354. def __init__(self):
  355. self._queue = []
  356. def __len__(self):
  357. return len(self._queue)
  358. def __iter__(self):
  359. return self._queue.__iter__()
  360. def __str__(self):
  361. return str(self._queue)
  362. def __repr__(self):
  363. return str(self._queue)
  364. def insert(self, toots):
  365. if(type(toots) == list):
  366. rv = False
  367. for t in toots:
  368. rv2 = self.insert(t)
  369. rv = rv or rv2
  370. return rv
  371. toot = toots
  372. for idx, t in enumerate(self._queue):
  373. if t["id"] == toot["id"]:
  374. return False
  375. # We could do better with bisect, but this will suffice for now
  376. if t["created_at"] >= toot["created_at"]: # comparing datetime
  377. self._queue.insert(idx, toot)
  378. return True
  379. self._queue.append(toot)
  380. return True
  381. def pop(self):
  382. return self._queue.pop()
  383. # =============================================================================
  384. # DAEMON
  385. # =============================================================================
  386. # -----------------------------------------------------------------------------
  387. bot_data = {}
  388. bot_sources = { Source.TYPE_MENTIONS : Source( "mentions",
  389. Source.TYPE_MENTIONS ) }
  390. bot_rules = []
  391. bot_mstdn = None
  392. bot_ctx = {}
  393. bot_config = None
  394. def daemon_initialize():
  395. logger = logging.getLogger( BOT_APPNAME )
  396. global bot_data
  397. global bot_sources
  398. global bot_rules
  399. global bot_mstdn
  400. global bot_ctx
  401. global bot_config
  402. # Load data
  403. # =========================================================================
  404. bot_data = load_data( args.data_file )
  405. #
  406. # =========================================================================
  407. if ( ( not "client_id" in bot_data
  408. or bot_data["client_id"] is None )
  409. or ( not "client_secret" in bot_data
  410. or bot_data["client_secret"] is None )
  411. or ( not "access_token" in bot_data
  412. or bot_data["access_token"] is None )
  413. or ( not "instance_url" in bot_data
  414. or bot_data["instance_url"] is None ) ):
  415. if not args.foreground:
  416. logger.error( "Missing login infos in saved in bot data" )
  417. logger.info( "Try to run in foreground to register" )
  418. exit(1)
  419. # -----------------------------------------------------------------
  420. if ( not "instance_url" in bot_data
  421. or bot_data["instance_url"] is None ):
  422. handle = input("Bot full handle : @")
  423. try:
  424. handle_splitted = handle.split("@")
  425. bot_data["username"] = handle_splitted[0]
  426. bot_data["instance"] = handle_splitted[1]
  427. bot_data["instance_url"] = "https://" + handle_splitted[1]
  428. if not args.simulate:
  429. save_data(bot_data, args.data_file)
  430. except Exception as e:
  431. logger.error( "Could not parse full handle '@{}'" \
  432. .format(handle) )
  433. exit(1)
  434. logger.info( "Bot is '@{}' on instance '{}'" \
  435. .format(bot_data["username"], bot_data["instance_url"]) )
  436. # -----------------------------------------------------------------
  437. if ( not "master_username" in bot_data
  438. or bot_data["master_username"] is None ):
  439. handle = input("Bot's master full handle : @")
  440. try:
  441. handle_splitted = handle.split("@")
  442. bot_data["master_username"] = handle_splitted[0]
  443. bot_data["master_instance"] = handle_splitted[1]
  444. if not args.simulate:
  445. save_data(bot_data, args.data_file)
  446. except Exception as e:
  447. logger.error( "Could not parse full handle '@{}'" \
  448. .format(handle) )
  449. exit(1)
  450. logger.info( "Bot's master is '@{}@{}'" \
  451. .format(bot_data["master_username"],
  452. bot_data["master_instance"]) )
  453. # -----------------------------------------------------------------
  454. if ( ( not "client_id" in bot_data
  455. or bot_data["client_id"] is None )
  456. or ( not "client_secret" in bot_data
  457. or bot_data["client_secret"] is None ) ):
  458. logger.warning( "App needs to be registered" )
  459. client_id, client_secret = \
  460. mastodon.Mastodon.create_app( BOT_APPNAME,
  461. api_base_url = bot_data["instance_url"] )
  462. bot_data["client_id"] = client_id
  463. bot_data["client_secret"] = client_secret
  464. if not args.simulate:
  465. save_data(bot_data, args.data_file)
  466. # -----------------------------------------------------------------
  467. mstdn = mastodon.Mastodon( client_id = bot_data["client_id"],
  468. client_secret = bot_data["client_secret"],
  469. api_base_url = bot_data["instance_url"] )
  470. # -----------------------------------------------------------------
  471. if ( not "access_token" in bot_data
  472. or bot_data["access_token"] is None ):
  473. login = ""
  474. password = ""
  475. if ( not "login" in bot_data
  476. or bot_data["login"] is None ):
  477. login = input("Bot login (email): ")
  478. password = getpass.getpass(prompt = "Password for {}: " \
  479. .format(login))
  480. else:
  481. login = bot_data["login"]
  482. password = getpass.getpass(prompt = "Password for {}: " \
  483. .format(login))
  484. bot_data["login"] = login
  485. if not args.simulate:
  486. save_data(bot_data, args.data_file)
  487. logger.warning("Getting access token for {}".format(bot_data["login"]))
  488. try:
  489. access_token = mstdn.log_in( username = bot_data["login"],
  490. password = password )
  491. except Exception as e:
  492. logger.error("Could not get access token: {}".format(str(e)))
  493. exit(1)
  494. bot_data["access_token"] = access_token
  495. if not args.simulate:
  496. save_data(bot_data, args.data_file)
  497. #
  498. # =========================================================================
  499. bot_mstdn = mastodon.Mastodon( client_id = bot_data["client_id"],
  500. client_secret = bot_data["client_secret"],
  501. access_token = bot_data["access_token"],
  502. api_base_url = bot_data["instance_url"] )
  503. # Get some context infos
  504. # =====================================================================
  505. try:
  506. bot = bot_mstdn.account_search("{}@{}" \
  507. .format(bot_data["username"],
  508. bot_data["instance"]))[0]
  509. except:
  510. bot = None
  511. try:
  512. master = bot_mstdn.account_search("{}@{}" \
  513. .format(bot_data["master_username"],
  514. bot_data["master_instance"]))[0]
  515. except:
  516. master = None
  517. bot_ctx = { "bot" : bot,
  518. "master": master }
  519. # -----------------------------------------------------------------------------
  520. def daemon_load_config():
  521. logger = logging.getLogger( BOT_APPNAME )
  522. global bot_data
  523. global bot_sources
  524. global bot_rules
  525. global bot_mstdn
  526. global bot_ctx
  527. global bot_config
  528. # Load config
  529. # =========================================================================
  530. if not os.path.isfile( args.config_file ):
  531. logger.error( "Failed to parse main config file '{}'" \
  532. .format(args.config_file) )
  533. exit(1)
  534. bot_config = configparser.ConfigParser( allow_no_value = True )
  535. bot_config.read( args.config_file )
  536. logger.info( "Parsing main config file '{}'".format(args.config_file) )
  537. # Test sections
  538. for section in [ "sources", "rules" ]:
  539. if not section in bot_config:
  540. logger.error( "Missing section '{}' in main config file '{}'" \
  541. .format(section,
  542. args.config_file) )
  543. exit(1)
  544. # Create sources
  545. # =========================================================================
  546. # Example conf extract:
  547. # ---------------------------------
  548. # [sources]
  549. # timeline = home, public
  550. # hashtag = inktober, inktober2017
  551. # ---------------------------------
  552. for source_type in bot_config["sources"]:
  553. for source_name in bot_config["sources"][source_type].split(","):
  554. source_name = source_name.strip()
  555. logger.info( "Adding {} source: '{}'".format(source_type,
  556. source_name) )
  557. source = Source( source_name, source_type )
  558. # Set the max id saved
  559. if source_name in bot_data["sources"][source_type]:
  560. source.last_id = bot_data["sources"] \
  561. [source_type] \
  562. [source_name]
  563. try:
  564. bot_sources[source_type].append(source)
  565. except KeyError:
  566. bot_sources[source_type] = [ source ]
  567. # Read config to create rules
  568. # =========================================================================
  569. for rules_filename in bot_config["rules"]:
  570. logger.info("Parsing rule file '{}'".format(rules_filename))
  571. config_rules = configparser.ConfigParser()
  572. config_rules.read(rules_filename)
  573. for section in config_rules.sections():
  574. if "at" in config_rules[section] :
  575. logger.info("Adding action rule '{}'".format(section))
  576. bot_rules.append( ActRule( section, config_rules[section] ) )
  577. else:
  578. logger.info("Adding reaction rule '{}'".format(section))
  579. bot_rules.append( ReactRule( section, config_rules[section] ) )
  580. # -----------------------------------------------------------------------------
  581. def daemon_main_loop():
  582. logger = logging.getLogger( BOT_APPNAME )
  583. global bot_data
  584. global bot_sources
  585. global bot_rules
  586. global bot_mstdn
  587. global bot_ctx
  588. global bot_config
  589. logger.info("Entering main loop")
  590. toots_queue = TootQueue()
  591. while True:
  592. # Collect sources, since last save
  593. # =====================================================================
  594. # ---------------------------------------------------------------------
  595. for timeline in bot_sources[Source.TYPE_TIMELINE]:
  596. logger.debug("Collecting toots from timeline {}".format(timeline))
  597. try:
  598. toots = bot_mstdn.timeline( str(timeline),
  599. since_id = timeline.last_id )
  600. toots_queue.insert(toots)
  601. try:
  602. timeline.last_id = toots[0]["id"]
  603. except:
  604. pass
  605. logger.debug("{} toots added".format(len(toots)))
  606. except IOError as e:
  607. logger.error("Mastodon IO Error: {}".format(e))
  608. # ---------------------------------------------------------------------
  609. for hashtag in bot_sources[Source.TYPE_HASHTAG]:
  610. logger.debug("Collecting toots from hashtag {}".format(hashtag))
  611. try:
  612. toots = bot_mstdn.timeline_hashtag( str(hashtag),
  613. since_id = hashtag.last_id )
  614. toots_queue.insert(toots)
  615. try:
  616. hashtag.last_id = toots[0]["id"]
  617. except:
  618. pass
  619. logger.debug("{} toots added".format(len(toots)))
  620. except IOError as e:
  621. logger.error("Mastodon IO Error: {}".format(e))
  622. # Collect notifications
  623. # ---------------------------------------------------------------------
  624. mentions = bot_sources[Source.TYPE_MENTIONS]
  625. logger.debug("Collecting mentions")
  626. try:
  627. notifs = bot_mstdn.notifications( since_id = mentions.last_id )
  628. for n in notifs:
  629. if n["type"] == "mention":
  630. logger.debug("Adding mention n°{} by @{}"\
  631. .format(n["status"]["id"],
  632. n["status"]["account"]["acct"]))
  633. toots_queue.insert(n["status"])
  634. try:
  635. mentions.last_id = notifs[0]["id"]
  636. except:
  637. pass
  638. logger.debug("{} notifications checked".format(len(notifs)))
  639. except IOError as e:
  640. logger.error("Mastodon IO Error: {}".format(e))
  641. # Treat toots
  642. # =====================================================================
  643. while True:
  644. try:
  645. t = toots_queue.pop()
  646. except:
  647. break
  648. logger.debug("treating toot n°{}".format(t["id"]))
  649. if is_toot_treated( bot_mstdn, t ):
  650. continue
  651. # Check for patterns we want to interact with. If the pattern
  652. # matches, we run the actions it corresponds to
  653. interacted_with = False
  654. for rule in bot_rules:
  655. try:
  656. if rule.matches( bot_mstdn,
  657. context = bot_ctx,
  658. data = bot_data,
  659. toot = t,
  660. really_act = not args.simulate ):
  661. logger.debug("rule '{}' matched!".format(rule))
  662. interacted_with = True
  663. break
  664. except AttributeError as e: # No matches() function
  665. if rule.debug:
  666. raise e
  667. # Else, skipping action rules...
  668. # Mark toot as treated. We specify if a rule really matched the
  669. # toot
  670. if not args.simulate:
  671. mark_toot_as_treated( bot_mstdn, t, interacted_with )
  672. # Save timelines last_ids
  673. # =====================================================================
  674. # ---------------------------------------------------------------------
  675. for timeline in bot_sources[Source.TYPE_TIMELINE]:
  676. bot_data["sources"][Source.TYPE_TIMELINE][str(timeline)] = \
  677. timeline.last_id
  678. if not args.simulate:
  679. save_data(bot_data, args.data_file)
  680. logger.debug("Bumping timeline {} to {}".format(str(timeline),
  681. timeline.last_id))
  682. # ---------------------------------------------------------------------
  683. for hashtag in bot_sources[Source.TYPE_HASHTAG]:
  684. bot_data["sources"][Source.TYPE_HASHTAG][str(hashtag)] = \
  685. hashtag.last_id
  686. if not args.simulate:
  687. save_data(bot_data, args.data_file)
  688. logger.debug("Bumping hashtag {} to {}".format(str(hashtag),
  689. hashtag.last_id))
  690. # ---------------------------------------------------------------------
  691. mentions = bot_sources[Source.TYPE_MENTIONS]
  692. bot_data["sources"][Source.TYPE_MENTIONS] = \
  693. mentions.last_id
  694. if not args.simulate:
  695. save_data(bot_data, args.data_file)
  696. logger.debug("Bumping mentions to {}".format(mentions.last_id))
  697. # Write spontaneous toots
  698. # =====================================================================
  699. sleep_time = bot_config["general"].getint("sleep", fallback=30)
  700. for rule in bot_rules:
  701. try:
  702. if rule.triggers( bot_mstdn,
  703. context = bot_ctx,
  704. data = bot_data,
  705. sleep = sleep_time,
  706. really_act = not args.simulate ):
  707. logger.debug("rule '{}' triggered!".format(rule))
  708. except AttributeError: # No triggers() function
  709. pass # Skiping reaction rules
  710. # Sleep a bit
  711. logger.debug("Sleeping {} seconds".format(sleep_time))
  712. time.sleep(sleep_time)
  713. # =============================================================================
  714. # SCRIPT
  715. # =============================================================================
  716. if args.foreground:
  717. daemon_initialize()
  718. daemon_load_config()
  719. daemon_main_loop()
  720. exit(0)
  721. # Run in background
  722. stdout = None
  723. stderr = None
  724. if args.debug:
  725. stdout = sys.stdout
  726. stderr = sys.stderr
  727. daemon_context = daemon.DaemonContext( stdout = stdout,
  728. stderr = stderr,
  729. working_directory = "." )
  730. daemon_context.signal_map = {
  731. signal.SIGTERM : "terminate",
  732. signal.SIGHUP : "terminate",
  733. signal.SIGUSR1 : daemon_load_config,
  734. }
  735. with daemon_context:
  736. daemon_initialize()
  737. daemon_load_config()
  738. daemon_main_loop()