main.py 33 KB

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