def main(): clientcred_file = BOT_NAME + '_clientcred.secret' usercred_file = BOT_NAME + '_usercred.secret' args = parse_args() if args.register: register(args, clientcred_file, usercred_file) sys.exit(0) mastodon = Mastodon(client_id=clientcred_file, access_token=usercred_file, api_base_url=BASE_URL) instance = mastodon.instance() print('Successfully logged into instance "{0}".'.format(instance.title)) run_bot(mastodon)
class Bot: def __init__(self, instance_url: str, access_token: str, websocket_mode=False): """Intiate a Mastodon bot. :param instance_url: (str) base URL for your Mastodon instance of choice, e.g. ``https://mastodon.technology``. :param access_token: (str) "Your access token" inside Preferences -> Development -> some application. :param websocket_mode: (bool) whether to use websockets for streaming """ self._instance = instance_url self._token = access_token self._handle = "" # will be like "*****@*****.**" self._atname = "" # will be like "@bot" self._triggers = [] self._websocket_mode = websocket_mode self._check_update_triggers = lambda o: self._check_triggers(o, UPDATE) self._check_notification_triggers = lambda o: self._check_triggers( o, NOTIFICATION) def _check_triggers(self, obj: dict, stream: str): """Handle events from ``mastodon.StreamListener.on_update()`` if ``stream=="update"``, and ``mastodon.StreamListener.on_notification()`` if ``stream=="notification"``. """ # set up expectations if stream == UPDATE: status = obj event = UPDATE elif stream == NOTIFICATION: status = obj["status"] event = obj["type"] else: return # if the bot is mentioned, remove the mention text from status content # before testing trigger mentioned_accts = [m["acct"] for m in status["mentions"]] if self._handle in mentioned_accts: to_be_removed = self._atname else: to_be_removed = "" for trig in self._triggers: if not trig.event == event: continue if trig.test( event, html_to_text(status["content"]).replace( to_be_removed, "", 1).strip(), ): # pass obj to trig. trig will decide which elements # to pass on to bot-developer-defined callback. reply = trig.invoke(obj) if reply: self._respond(status, reply) def _respond(self, status: dict, content): """Reply to a status with content, boost, or favourite it. :param status: (dict) the status to respond to. :param content: When ``content`` is a string, simply reply with it, keeping everything else as Mastodon.py decides. When it is an instance of the ``mastobot.Reply`` class, all its arguments will be passed on to Mastodon.py. The rest are left in their default state. When it is ``mastobot.Boost``, boost ``status``. Ditto for ``Favourite``. When it is a list/tuple, recursively call ``self._respond(status, n)`` for each ``n`` in content. """ if not content: raise ValueError(f"Response to {status['id']} empty; aborted") if type(content) in (list, tuple): for n in content: self._respond(status, n) return elif content == Boost: self._bot.status_reblog(status["id"]) return elif content == Favourite: self._bot.status_favourite(status["id"]) return elif type(content) == str: args = { STATUS: content, VISIBILITY: status[VISIBILITY], SPOILER_TEXT: status[SPOILER_TEXT], } elif isinstance(content, Reply): args = { STATUS: content.text, VISIBILITY: content.visibility or status[VISIBILITY], SPOILER_TEXT: content.spoiler_text or status[SPOILER_TEXT], } self._bot.status_post(in_reply_to_id=status["id"], **args) # decorator generators def on_mention(self, expectation, validation=EQUALS, case_sensitive=False): """Listen to mentions and invoke a callback with a ``Status`` object as argument. :param expectation: (str or callable) string, regex string or callable that evaluates to True if the status content is what you want. :param validation: (str) may be "equals", "contains", "regex" or "evaluate". """ def decorator(callback): self._triggers.append( Trigger( event=MENTION, validation=validation, expectation=expectation, callback=callback, case_sensitive=case_sensitive, )) return decorator def on_home_update(self, expectation, validation=EQUALS, case_sensitive=False): """Listen to updates on the home timeline and invoke a callback with a ``Status`` object as argument. :param expectation: (str or callable) string, regex string or callable that evaluates to True if the status content is what you want. :param validation: (str) may be "equals", "contains", "regex" or "evaluate". """ def decorator(callback): self._triggers.append( Trigger( event=UPDATE, validation=validation, expectation=expectation, callback=callback, case_sensitive=case_sensitive, )) return decorator # execution def run(self): """Start bot. After all listeners (triggers) are set, invoke ``bot.run()``. """ self._bot = Mastodon(api_base_url=self._instance, access_token=self._token) print("Connected to " + self._instance) # keep a record of what this bot is called # so that "@atname" can be removed when necessary. me = self._bot.account_verify_credentials() self._handle = me["acct"] self._atname = "@" + me["username"] # register stream listeners if not self._websocket_mode: # use Mastodon.py StreamListener self._user_stream = StreamListener() self._user_stream.on_update = self._check_update_triggers self._user_stream.on_notification = self._check_notification_triggers self._bot.stream_user(self._user_stream) else: # use ws interface I implemented myself self._user_stream = WebsocketListener(self._bot.instance(), self._token, stream="user") self._user_stream.on_update = self._check_update_triggers self._user_stream.on_notification = self._check_notification_triggers self._user_stream.start_stream()
# 'pytooterapp', # api_base_url = 'https://mstdn.jp', # to_file = 'pytooter_clientcred.secret' # ) # Log in - either every time, or use persisted mastodon = Mastodon(client_id='pytooter_clientcred.secret', api_base_url='https://mstdn.jp') mastodon.log_in('your-registered-email-address', 'your-password', to_file='pytooter_usercred.secret') # Create actual API instance mastodon = Mastodon(client_id='pytooter_clientcred.secret', client_secret=None, access_token='pytooter_usercred.secret', api_base_url='https://mstdn.jp', debug_requests=False, ratelimit_method='pace', ratelimit_pacefactor=1.1, request_timeout=300, mastodon_version=None, version_check_mode='none') description = mastodon.instance() user_count = description['stats']['user_count'] # /api/v1/accounts/:id/followers
import sys from mastodon import Mastodon url = sys.argv[1] cid_file = 'client_id.txt' token_file = 'access_token.txt' mastodon = Mastodon(client_id=cid_file, access_token=token_file, api_base_url=url) # インスタンスのドメイン名を表示する instance = mastodon.instance() print(instance['uri'])
class PineappleBot(StreamListener): """ Main bot class We subclass StreamListener so that we can use it as its own callback """ INITIALIZING = 0 STARTING = 1 RUNNING = 2 STOPPING = 3 class Config(dict): def __init__(self, bot, filename): dict.__init__(self) self._filename = filename self._cfg = ConfigObj(filename, create_empty=True, interpolation="configparser") self._bot = bot def __getattr__(self, key): if self[key]: return self[key] else: warnings.warn("The {} setting does not appear in {}. Setting the self.config value to None.".format(key, self._filename), RuntimeWarning, 1) return None def __setattr__(self, key, value): self[key] = value def __delattr(self, key): del self[key] def load(self, name=None): """ Load section <name> from the config file into this object, replacing/overwriting any values currently cached in here.""" if (name != None): self._name = name self._cfg.reload() if (name not in self._cfg.sections): self._bot.log("config", "Section {} not in {}, aborting.".format(self._name, self._filename)) return False self._bot.log("config", "Loading configuration from {}".format(self._filename)) if "DEFAULT" in self._cfg.sections: self.update(self._cfg["DEFAULT"]) self.update(self._cfg[self._name]) return True def save(self): """ Save back out to the config file. """ self._cfg.reload() for attr, value in self.items(): if attr[0] != '_': if "DEFAULT" not in self._cfg or (not (attr in self._cfg["DEFAULT"] and self._cfg["DEFAULT"][attr] == value)): if isinstance(value, (list, tuple)): value = ",".join([str(v) for v in value]) self._cfg[self._name][attr] = str(value) self._bot.log("config", "Saving configuration to {}...".format(self._filename)) self._cfg.write() self._bot.log("config", "Done.") return True def __init__(self, cfgname, name=None, log_to_stderr=True, interactive=False, verbose=False): if (name is None): name = self.__class__.__name__ self.name = name self.state = PineappleBot.INITIALIZING self.alive = threading.Condition() self.threads = [] self.reply_funcs = [] self.report_funcs = [] self.mastodon = None self.account_info = None self.username = None self.default_visibility = None self.default_sensitive = None self.stream = None self.interactive = interactive self.verbose = verbose self.log_to_stderr = log_to_stderr self.log_name = self.name + ".log" self.log_file = open(self.log_name, "a") self.config = PineappleBot.Config(self, cfgname) self.init() # Call user init to initialize bot-specific properties to default values if not self.config.load(self.name): return if not self.login(): return self.startup() def log(self, id, msg): if (id == None): id = self.name else: id = self.name + "." + id ts = datetime.now() msg_f = "[{0:%Y-%m-%d %H:%M:%S}] {1}: {2}".format(ts, id, msg) if self.log_file.closed or self.log_to_stderr: print(msg_f, file=sys.stderr) elif not self.log_file.closed: print(msg_f, file=self.log_file) def startup(self): self.state = PineappleBot.STARTING self.log(None, "Starting {0} {1}".format(self.__class__.__name__, self.name)) try: self.start() except Exception as e: self.log(None, "Fatal exception: {}\n{}".format(repr(e), traceback.format_exc())) return def interval_threadproc(f): self.log(f.__name__, "Started") t = datetime.now() tLast = t while (True): self.alive.acquire() t = datetime.now() interval = interval_next(f, t, tLast) if (interval == 0): try: f() except Exception as e: error = "Exception encountered in @interval function: {}\n{}".format(repr(e), traceback.format_exc()) self.report_error(error, f.__name__) t = datetime.now() interval = interval_next(f, t, t) if self.verbose: self.log(f.__name__ + ".debug", "Next wait interval: {}s".format(interval)) tLast = t self.alive.wait(max(interval, 1)) if (self.state == PineappleBot.STOPPING): self.alive.release() self.log(f.__name__, "Shutting down") return 0 else: self.alive.release() for fname, f in inspect.getmembers(self, predicate=inspect.ismethod): if hasattr(f, "interval") or hasattr(f, "schedule"): t = threading.Thread(args=(f,), target=interval_threadproc) t.start() self.threads.append(t) if hasattr(f, "reply"): self.reply_funcs.append(f) if hasattr(f, "error_reporter"): self.report_funcs.append(f) if len(self.reply_funcs) > 0: self.stream = self.mastodon.stream_user(self, async=True) credentials = self.mastodon.account_verify_credentials() self.account_info = credentials self.username = credentials["username"] self.default_visibility = credentials["source"]["privacy"] self.default_sensitive = credentials["source"]["sensitive"] self.state = PineappleBot.RUNNING self.log(None, "Startup complete.") def shutdown(self): self.alive.acquire() self.state = PineappleBot.STOPPING self.log(None, "Stopping {0} {1}".format(self.__class__.__name__, self.name)) self.alive.notify_all() self.alive.release() if self.stream: self.stream.close() self.stop() self.config.save() self.log_file.close() def login(self): if self.interactive and not self.interactive_login(): self.log("api", "Interactive login failed, exiting.") return False elif "domain" not in self.config: self.log("api", "No domain set in config and interactive = False, exiting.") return False elif "client_id" not in self.config: self.log("api", "No client id set in config and interactive = False, exiting.") return False elif "client_secret" not in self.config: self.log("api", "No client secret set in config and interactive = False, exiting.") return False elif "access_token" not in self.config: self.log("api", "No access key set in config and interactive = False, exiting.") return False self.mastodon = Mastodon(client_id = self.config.client_id, client_secret = self.config.client_secret, access_token = self.config.access_token, api_base_url = self.config.domain) #debug_requests = True) return True def interactive_login(self): try: if (not hasattr(self, "domain")): domain = input("{0}: Enter the instance domain [mastodon.social]: ".format(self.name)) domain = domain.strip() if (domain == ""): domain = "mastodon.social" self.config.domain = domain # We have to generate these two together, if just one is # specified in the config file it's no good. if (not hasattr(self, "client_id") or not hasattr(self, "client_secret")): client_name = input("{0}: Enter a name for this bot or service [{0}]: ".format(self.name)) client_name = client_name.strip() if (client_name == ""): client_name = self.name self.config.client_id, self.config.client_secret = Mastodon.create_app(client_name, api_base_url="https://"+self.config.domain) # TODO handle failure self.config.save() if (not hasattr(self, "access_token")): email = input("{0}: Enter the account email: ".format(self.name)) email = email.strip() password = getpass.getpass("{0}: Enter the account password: "******"https://"+self.config.domain) self.config.access_token = mastodon.log_in(email, password) self.config.save() except ValueError as e: self.log("login", "Could not authenticate with {0} as '{1}':" .format(self.config.domain, email)) self.log("login", str(e)) self.log("debug", "using the password {0}".format(password)) return False return True except KeyboardInterrupt: return False @error_reporter def default_report_handler(self, error): if self.mastodon and "admin" in self.config: self.mastodon.status_post(("@{} ERROR REPORT from {}:\n{}" .format(self.config.admin, self.name, error))[:500], visibility="direct") def report_error(self, error, location=None): """Report an error that occurred during bot operations. The default handler tries to DM the bot admin, if one is set, but more handlers can be added by using the @error_reporter decorator.""" if location == None: location = inspect.stack()[1][3] self.log(location, error) for f in self.report_funcs: f(error) def on_notification(self, notif): self.log("debug", "Got a {} from {} at {}".format(notif["type"], notif["account"]["username"], notif["created_at"])) if (notif["type"] == "mention"): for f in self.reply_funcs: try: f(notif["status"], notif["account"]) except Exception as e: error = "Fatal exception: {}\n{}".format(repr(e), traceback.format_exc()) self.report_error(error, f.__name__) def on_close(self): # Attempt to re-open the connection, since this is usually the result of # the server just dropping the connection for one reason or another. self.log(None, "Dropped streaming connection to {}".format(self.config.domain)) self.stream.close() old_timeout = self.mastodon.request_timeout # Don't wait forever for the instance to respond to the /api/v1/instance endpoint inside Mastodon.py #self.mastodon.request_timeout = (10, 10) print("thing1") try: self.mastodon.instance() except mastodon.Mastodon.MastodonNetworkError as e: self.log("Instance appears to have gone down") print("thing2") wait = 10 while(True): try: self.log(None, "Attempting to reinitialize in {}s...".format(wait)) time.sleep(wait) self.stream = self.mastodon.stream_user(self, async=True) # Call the instance API first, so that we don't get stuck in stream_user # (timeout doesn't work there for some reason) self.mastodon.instance() # If we get here without erroring, success self.mastodon.request_timeout = old_timeout self.log(None, "Successfully reinitialized streaming connection.") break except mastodon.Mastodon.MastodonNetworkError: self.log(None, "Timed out") wait *= 2 continue def get_reply_visibility(self, status_dict): """Given a status dict, return the visibility that should be used. This behaves like Mastodon does by default. """ # Visibility rankings (higher is more limited) visibility = ("public", "unlisted", "private", "direct") default_visibility = visibility.index(self.default_visibility) status_visibility = visibility.index(status_dict["visibility"]) return visibility[max(default_visibility, status_visibility)] # defaults, should be replaced by concrete bots with actual implementations # (if necessary, anyway) def init(self): pass def start(self): pass def stop(self): pass
class Ivory(): """ The main Ivory class, which programmatically handles reports pulled from the Mastodon API. """ def __init__(self, raw_config): """ Runs Ivory. """ # **Validate the configuration** config = IvoryConfig(raw_config) # **Set up logger** self._logger = logging.getLogger(__name__) self._logger.info("Ivory version %s starting", constants.VERSION) # **Load Judge and Rules** self._logger.info("parsing rules") if 'reports' in config: self.report_judge = ReportJudge(config['reports'].get("rules")) else: self._logger.debug("no report rules detected") self.report_judge = None if 'pendingAccounts' in config: self.pending_account_judge = PendingAccountJudge( config['pendingAccounts'].get("rules")) else: self._logger.debug("no pending account rules detected") self.pending_account_judge = None # **Initialize and verify API connectivity** self._api = Mastodon(access_token=config['token'], api_base_url=config['instanceURL']) self._logger.debug("mastodon API wrapper initialized") # 2.9.1 required for moderation API if not self._api.verify_minimum_version("2.9.1"): self._logger.error( "This instance is not updated to 2.9.1 - this version is required for the Moderation API %s", self._api.users_moderated) exit(1) self._logger.debug("minimum version verified; should be ok") # grab some info which could be helpful here self.instance = self._api.instance() self.user = self._api.account_verify_credentials() # log a bunch of shit self._logger.info("logged into %s as %s", self.instance['uri'], self.user['username']) self._logger.debug("instance info: %s", self.instance) self._logger.debug("user info: %s", self.user) # **Set some variables from config** if 'waitTime' not in config: self._logger.info( "no waittime specified, defaulting to %d seconds", constants.DEFAULT_WAIT_TIME) self.wait_time = config.get("waitTime", constants.DEFAULT_WAIT_TIME) self.dry_run = config.get('dryRun', False) def handle_unresolved_reports(self): """ Handles all unresolved reports. """ reports = self._api.admin_reports() for report in reports: self.handle_report(report) def handle_report(self, report: dict): """ Handles a single report. """ self._logger.info("handling report #%d", report['id']) (punishment, rules_broken) = self.report_judge.make_judgement(report) if rules_broken: self._logger.info("report breaks these rules: %s", rules_broken) if punishment is not None: self._logger.info("handling report with punishment %s", punishment) self.punish(report['target_account']['id'], punishment, report['id']) def handle_pending_accounts(self): """ Handle all accounts in the pending account queue. """ accounts = self._api.admin_accounts(status="pending") for account in accounts: self.handle_pending_account(account) def handle_pending_account(self, account: dict): """ Handle a single pending account. """ self._logger.info("handling pending user %s", account['username']) (punishment, rules_broken) = self.pending_account_judge.make_judgement(account) if rules_broken: self._logger.info("pending account breaks these rules: %s", rules_broken) if punishment is not None: self._logger.info("handling report with punishment %s", punishment) self._logger.debug("punishment cfg: %s", punishment.config) self.punish(account['id'], punishment) def punish(self, account_id, punishment: Punishment, report_id=None): if self.dry_run: self._logger.info("ignoring punishment; in dry mode") return maxtries = 3 tries = 0 while True: try: if punishment.type == constants.PUNISH_REJECT: self._api.admin_account_reject(account_id) elif punishment.type == constants.PUNISH_WARN: self._api.admin_account_moderate( account_id, None, report_id, text=punishment.config.get('message')) elif punishment.type == constants.PUNISH_DISABLE: self._api.admin_account_moderate( account_id, "disable", report_id, text=punishment.config.get('message')) elif punishment.type == constants.PUNISH_SILENCE: self._api.admin_account_moderate( account_id, "silence", report_id, text=punishment.config.get('message')) elif punishment.type == constants.PUNISH_SUSPEND: self._api.admin_account_moderate( account_id, "suspend", report_id, text=punishment.config.get('message')) else: # whoops raise NotImplementedError() break except MastodonGatewayTimeoutError as err: self._logger.warn( "gateway timed out. ignoring for now, if that didn't do it we'll get it next pass..." ) break def run(self): self._logger.info("starting moderation pass") try: if self.report_judge: self.handle_unresolved_reports() if self.pending_account_judge: self.handle_pending_accounts() self._logger.info("moderation pass complete") except MastodonError: self._logger.exception( "enountered an API error. waiting %d seconds to try again", self.wait_time) def watch(self): """ Runs handle_unresolved_reports() on a loop, with a delay specified in the "waittime" field of the config. """ while True: starttime = time.time() self.run() time_to_wait = self.wait_time - (time.time() - starttime) if time_to_wait > 0: self._logger.debug("waiting for %.4f seconds", time_to_wait) time.sleep(time_to_wait) else: self._logger.warn( "moderation pass took longer than waitTime - this will cause significant drift. you may want to increase waitTime" )
tl_text += " (Compare " + ", ".join(attachments) + ")" tl_text += figures_text +"\n" return(tl_text) api = Mastodon( client_id = sys.argv[2], access_token = sys.argv[3], api_base_url = sys.argv[1] ) while(True): with open('samplepaper.tex', 'r') as template_file: latex_base = template_file.read() instance_info = api.instance() user_info = api.account_verify_credentials() home_tl = convert_tl(api.timeline_home()) local_tl = convert_tl(api.timeline_local()) fed_tl = convert_tl(api.timeline_public()) latex_base = latex_base.replace("INSTANCENAME", instance_info.title) latex_base = latex_base.replace("INSTANCEURL", "https://" + instance_info.uri) latex_base = latex_base.replace("INSTANCEDESC", cleanhtml(instance_info.description)) latex_base = latex_base.replace("USERNAME", user_info.acct) latex_base = latex_base.replace("USERURL", user_info.url) latex_base = latex_base.replace("HOMETL", home_tl) latex_base = latex_base.replace("LOCALTL", local_tl) latex_base = latex_base.replace("FEDTL", fed_tl)