class Steemit: def __init__(self, username, steem_key): self.username = username self.client = Client(keys=[steem_key]) def get_account(self): return self.client.account(self.username) def get_vp(self): account = self.get_account() return account.vp() def get_rc(self): account = self.get_account() return account.rc() def post_vote(self, voting_user, voting_link, weight=10000): try: op = Operation( 'vote', { "voter": self.username, "author": voting_user, "permlink": voting_link.split(f'{voting_user}/')[1], "weight": weight, }) result = self.client.broadcast(op) logging.info(result) except: logging.info('Broadcast Fail!') return False return True
def handle(self, *args, **options): client = Client() acc = client.account(settings.CURATION_BOT_ACCOUNT) for index, transaction in acc.history( filter=["delegate_vesting_shares"], order="asc", only_operation_data=False, ): op = transaction["op"][1] if op.get("delegator") == settings.CURATION_BOT_ACCOUNT: continue if op.get("delegator") in BLACKLIST: continue try: sponsor = Sponsor.objects.get(username=op.get("delegator")) sponsor.delegation_modified_at = add_tz_info( parse(transaction["timestamp"])) except Sponsor.DoesNotExist: sponsor = Sponsor( username=op.get("delegator"), ) sponsor.delegation_created_at = add_tz_info( parse(transaction["timestamp"])) sponsor.delegation_amount = Amount(op.get("vesting_shares")).amount sponsor.save() print(f"Delegation of {op['delegator']}:" f" {op['vesting_shares']} is saved.")
class TransferListener: def __init__(self, account=None, posting_key=None, active_key=None, nodes=None): # default node is api.steemit.com if not nodes: nodes = ["https://api.steemit.com", ] self.account = account self.vote_client = Client(keys=[posting_key, ], nodes=nodes) self.refund_client = Client(keys=[active_key, ], nodes=nodes) def get_incoming_transfers(self): stop_at = datetime.now() - timedelta(hours=24) acc = self.vote_client.account(self.account) transfers = [] for _, transaction in acc.history( filter=["transfer"], only_operation_data=False, stop_at=stop_at, ): op = transaction["op"][1] if op["to"] != self.account: continue transfers.append(transaction) return transfers def poll_transfers(self): while True: try: for transfer in self.get_incoming_transfers(): print(transfer) except Exception as error: print(error) time.sleep(3)
def handle(self, *args, **options): active_key = getpass.getpass( f"Active key of f{settings.SPONSORS_ACCOUNT}") client = Client(keys=[active_key, ], nodes=["https://api.hivekings.com"]) account = client.account(settings.SPONSORS_ACCOUNT) one_week_ago = now() - timedelta(days=7) sponsors = Sponsor.objects.filter( delegation_created_at__lt=one_week_ago, delegation_amount__gt=0, opt_in_to_rewards=True, ) print(f"{sponsors.count()} sponsors found.") total_shares = Sponsor.objects.aggregate( total=Sum("delegation_amount"))["total"] print(f"{total_shares} VESTS delegated.") liquid_funds = Amount(account.raw_data["balance"]) print(f"dpoll.sponsors has {liquid_funds}.") one_percent_share = liquid_funds.amount / 100 transfers = [] for sponsor in sponsors: shares_in_percent = Decimal( sponsor.delegation_amount * 100 / total_shares) amount = "%.3f" % (one_percent_share * shares_in_percent) transfers.append({ "from": settings.SPONSORS_ACCOUNT, "to": sponsor.username, "amount": f"{amount} STEEM", "memo": f"Greetings {sponsor.username}," " Thank you for supporting dPoll. " "Here is your weekly rewards." }) op_list = [Operation('transfer', op) for op in transfers] client.broadcast(op_list) print("Rewards are sent!")
class DcomClient(commands.Bot): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.config = kwargs.get("dcom_config") self.lightsteem_client = LightsteemClient( nodes=self.config["steem_nodes"], keys=[self.config["bot_posting_key"]]) self.community_name = self.config.get("community_name") self.registration_channel = self.config.get("registration_channel") self.registration_account = self.config.get("registration_account") self.role_name_for_registered_users = self.config.get( "role_name_for_registered_users") self.curation_channels = self.config.get("curation_channels") self.mongo_client = MongoClient(self.config.get("M0NGO_URI")) self.mongo_database = self.mongo_client["dcom"] self.patron_role = self.config.get("patron_role") self.bot_log_channel = self.config.get("bot_log_channel") self.account_for_vp_check = self.config.get("account_for_vp_check") self.limit_on_maximum_vp = self.config.get("limit_on_maximum_vp") self.bot_account = self.config.get("bot_account") self.auto_curation_vote_weight = 20 @asyncio.coroutine def on_ready(self): print(self.user.name) print(self.user.id) if len(self.servers) > 1: sys.exit('This bot may run in only one server.') print(f'Running on {self.running_on.name}') @asyncio.coroutine async def on_member_update(self, before, after): # This callback works every time a member is updated on Discord. # We use this to sync members having "patron" as a role. before_roles = [r.name for r in before.roles] after_roles = [r.name for r in after.roles] channel = discord.Object(self.bot_log_channel) if self.patron_role in before_roles and \ self.patron_role not in after_roles: # looks like the user lost access to patron role await self.send_message( channel, f":broken_heart: {after.mention} lost patron rights.") self.mongo_database["patrons"].delete_many( {"discord_id": str(after)}) elif self.patron_role in after_roles and \ self.patron_role not in before_roles: # we have a new patron await self.send_message( channel, f":green_heart: {after.mention} gained patron rights.") self.mongo_database["patrons"].insert({"discord_id": str(after)}) def say_error(self, error): return self.say(f"**Error:** {error}") def say_success(self, message): return self.say(f":thumbsup: {message}") def upvote(self, post_content, weight, author=None, permlink=None): vote_op = Operation( 'vote', { 'voter': self.config.get("bot_account"), 'author': author or post_content.get("author"), 'permlink': permlink or post_content.get("permlink"), 'weight': weight * 100 }) self.lightsteem_client.broadcast(vote_op) def refund(self, to, amount): transfer_op = Operation( 'transfer', { 'from': self.registration_account, 'to': to, 'memo': 'Successful registration. ' f'Welcome to {self.community_name}.', 'amount': amount, }) # swap posting key with active key # workaround for a lightsteem quirk old_keys = self.lightsteem_client.keys try: self.lightsteem_client.keys = [ self.config.get("registration_account_active_key") ] self.lightsteem_client.broadcast(transfer_op) finally: self.lightsteem_client.keys = old_keys def steem_username_is_valid(self, username): try: resp = self.lightsteem_client('condenser_api').get_accounts( [username]) return bool(len(resp)) except Exception as e: # retry logic on node failures return self.steem_username_is_valid(username) def get_verification_code(self, steem_username, discord_author): old_verification_code = self.mongo_database["verification_codes"]. \ find_one({ "verified": False, "steem_username": steem_username, "discord_id": str(discord_author), }) if old_verification_code: verification_code = old_verification_code["code"] self.mongo_database["verification_codes"].update_one( {"code": old_verification_code["code"]}, {'$set': { "last_update": datetime.datetime.utcnow() }}) else: verification_code = str(uuid.uuid4()) self.mongo_database["verification_codes"].insert({ "steem_username": steem_username, "discord_id": str(discord_author), "discord_backend_id": discord_author.id, "code": verification_code, "verified": False, "last_update": datetime.datetime.utcnow(), }) return verification_code def get_a_random_patron_post(self): # Get a list of verified discord members having the role "patron: patron_users = list(self.mongo_database["patrons"].find()) patron_users_ids = [u["discord_id"] for u in patron_users] verified_patrons = list(self.mongo_database["verification_codes"] \ .find({ "verified": True, "discord_id": {"$in": patron_users_ids}} ).distinct("steem_username")) # Remove the patrons already voted in the last 24h. curated_authors = self.get_curated_authors_in_last_24_hours() verified_patrons = set(verified_patrons) - curated_authors print("Patrons", verified_patrons) # Prepare a list of patron posts posts = [] for patron in verified_patrons: posts.append(self.get_last_votable_post(patron)) if len(posts): # We have found some posts, shuffle it and # return the first element. random.shuffle(posts) return posts[0] def get_curated_authors_in_last_24_hours(self): """ Returns a set of authors curated by the self.bot_account. """ account = self.lightsteem_client.account(self.bot_account) one_day_ago = datetime.datetime.utcnow() - \ datetime.timedelta(days=1) voted_authors = set() for op in account.history(filter=["vote"], stop_at=one_day_ago): if op["voter"] != self.bot_account: continue voted_authors.add(op["author"]) return voted_authors def get_last_votable_post(self, patron): """ Returns a list of [author, permlink] lists. Output of this function is designed to be used in automatic curation. """ posts = self.lightsteem_client.get_discussions_by_blog({ "limit": 7, "tag": patron }) for post in posts: # exclude reblogs if post["author"] != patron: continue # check if it's votable created = parse(post["created"]) diff_in_seconds = (datetime.datetime.utcnow() - created). \ total_seconds() # check if the post's age is lower than 6.5 days if diff_in_seconds > 561600: break # check if we already voted on that. voters = [v["voter"] for v in post["active_votes"]] if self.account_for_vp_check in voters or \ self.bot_account in voters: continue return post["author"], post["permlink"] @property def running_on(self): return list(self.servers)[0] async def verify(self, memo, amount, _from): # check the memo is a valid verification code, first. verification_code = self.mongo_database["verification_codes"]. \ find_one({ "code": memo, "verified": False, "steem_username": _from, }) if not verification_code: return # add the "registered" role to the user server = self.running_on member = server.get_member(verification_code["discord_backend_id"]) role = discord.utils.get(server.roles, name=self.role_name_for_registered_users) await self.add_roles(member, role) # send an informative message to the channel about the verification # status channel = discord.Object(self.registration_channel) await self.send_message( channel, f":wave: Success! **{verification_code['steem_username']}**" f" has been successfully registered with " f" <@{verification_code['discord_backend_id']}>.") # mark the code as verified self.mongo_database["verification_codes"].update_one( {"code": memo}, {'$set': { "verified": True }}) # refund the user self.refund(verification_code["steem_username"], amount) async def check_transfers(self): processed_memos = set() await self.wait_until_ready() while not self.is_closed: print("[task start] check_transfers()") # If there are no waiting verifications # There is no need to poll the account history one_hour_ago = datetime.datetime.utcnow() - \ datetime.timedelta(minutes=60) waiting_verifications = self.mongo_database["verification_codes"]. \ count({"last_update": { "$gte": one_hour_ago}, "verified": False}) if waiting_verifications > 0: print(f"Waiting {waiting_verifications} verifications. " f"Checking transfers") try: # Poll the account history and check for the # STEEM transfers account = self.lightsteem_client.account( self.registration_account) for op in account.history(stop_at=one_hour_ago, filter=["transfer"]): if op.get("memo") in processed_memos: continue if op.get("from") == self.registration_account: continue await self.verify(op.get("memo"), op.get("amount"), op.get("from")) processed_memos.add(op.get("memo")) except Exception as e: print(e) print("[task finish] check_transfers()") await asyncio.sleep(10) async def auto_curation(self): channel = discord.Object(self.bot_log_channel) await self.wait_until_ready() while not self.is_closed: try: print("[task start] auto_curation()") # vp must be eligible for automatic curation acc = self.lightsteem_client.account(self.account_for_vp_check) if acc.vp() >= int(self.limit_on_maximum_vp): # get the list of registered patrons post = self.get_a_random_patron_post() if post: author, permlink = post self.upvote(None, self.auto_curation_vote_weight, author=author, permlink=permlink) await self.send_message( channel, f"**[auto-curation round]**", embed=get_vote_details( author, permlink, self.auto_curation_vote_weight, self.bot_account)) else: await self.send_message( channel, f"**[auto-curation round]** Couldn't find any " f"suitable post. Skipping.") else: await self.send_message( channel, f"**[auto-curation round]** Vp is not enough." f" ({acc.vp()}) Skipping.") print("[task finish] auto_curation()") except Exception as e: print(e) await asyncio.sleep(900)
def handle(self, *args, **options): """Entry point for the Django management command""" client = Client(keys=[ settings.PROMOTION_ACCOUNT_ACTIVE_KEY, ], nodes=["https://api.hivekings.com"]) acc = client.account(settings.PROMOTION_ACCOUNT) for _, transaction in acc.history( filter=["transfer"], only_operation_data=False, ): op = transaction["op"][1] # only process incoming transactions if op["from"] == settings.PROMOTION_ACCOUNT: continue try: promotion_transaction = PromotionTransaction.objects.get( trx_id=transaction["trx_id"]) # if transaction already exists, means what we already # processed it. so, we can skip it safely. print(f"This transaction is already processed." f"Skipping. ({transaction['trx_id']})") continue except PromotionTransaction.DoesNotExist: amount = Amount(op["amount"]) promotion_amount = '%.3f' % float(amount.amount) # create a base transaction first promotion_transaction = PromotionTransaction( trx_id=transaction["trx_id"], from_user=op["from"], amount=promotion_amount, memo=op["memo"], ) promotion_transaction.save() # check if the asset is valid if amount.symbol == "STEEM": print(f"Invalid Asset. Refunding. ({op['amount']})") self.refund(client, op["from"], op["amount"], "Only SBD is accepted.") continue # check if the memo is valid memo = op["memo"] try: author = memo.split("@")[1].split("/")[0] permlink = memo.split("@")[1].split("/")[1] except IndexError as e: print(f"Invalid URL. Refunding. ({memo})") self.refund(client, op["from"], op["amount"], "Invalid URL") continue # check if the poll exists try: question = Question.objects.get(username=author, permlink=permlink) except Question.DoesNotExist: print(f"Invalid poll. Refunding. ({memo})") self.refund(client, op["from"], op["amount"], "Invalid poll.") continue # if the poll is closed, don't mind promoting it. if question.expire_at < timezone.now(): print(f"Expired poll. Refunding. ({memo})") self.refund(client, op["from"], op["amount"], "Expired poll.") continue promotion_transaction.author = author promotion_transaction.permlink = permlink promotion_transaction.save() # update the related poll's promotion amount if not question.promotion_amount: question.promotion_amount = float(amount.amount) else: question.promotion_amount += float(amount.amount) question.save() print(f"{author}/{permlink} promoted with " f"{promotion_amount} STEEM.")
class TransferListener: def __init__(self, account, posting_key=None, active_key=None, nodes=None, db=None, probability_dimensions=None, min_age_to_vote=None, max_age_to_vote=None, minimum_vp_to_vote=None, vote_price=None): self.db = db self.nodes = nodes or ["https://api.steemit.com"] self.account = account self.client = Client(loglevel=logging.INFO) self.vote_client = Client(keys=[ posting_key, ], nodes=nodes) self.refund_client = Client(keys=[ active_key, ], nodes=nodes) self.min_age_to_vote = min_age_to_vote or 300 self.max_age_to_vote = max_age_to_vote or 43200 self.minimum_vp_to_vote = minimum_vp_to_vote or 80 self.vote_percent = VotePercent( probability_dimensions=probability_dimensions) self.vote_price = Amount(vote_price) or Amount("1.000 STEEM") def get_incoming_transfers(self): stop_at = datetime.now() - timedelta(hours=3) acc = self.client.account(self.account) transfers = [] for _, transaction in acc.history( filter=["transfer"], only_operation_data=False, stop_at=stop_at, ): op = transaction["op"][1] if op["to"] != self.account: continue transfers.append(transaction) return transfers def poll_transfers(self): while True: try: for transfer in self.get_incoming_transfers(): self.process_transfer(transfer) except Exception as error: print(error) time.sleep(3) def in_global_blacklist(self, author): url = "http://blacklist.usesteem.com/user/" + author response = requests.get(url).json() return bool(len(response["blacklisted"])) def get_content(self, author, permlink, retries=None): if not retries: retries = 0 try: content = self.client.get_content(author, permlink) except Exception as error: if retries > 5: raise return self.get_content(author, permlink, retries=retries + 1) return content def get_active_votes(self, author, permlink, retries=None): if not retries: retries = 0 try: active_votes = self.client.get_active_votes(author, permlink) except Exception as error: if retries > 5: raise return self.get_active_votes(author, permlink, retries=retries + 1) return [v["voter"] for v in active_votes] def refund(self, to, amount, memo, incoming_trx_id): if self.db.database.transfers.count({ "incoming_trx_id": incoming_trx_id, "status": TransferStatus.REFUNDED.value }): logger.info("Already refunded. (TRX id: %s)", incoming_trx_id) return try: op = Operation('transfer', { "from": self.account, "to": to, "amount": amount, "memo": memo, }) self.refund_client.broadcast(op) status = TransferStatus.REFUNDED.value except Exception as e: print(e) status = TransferStatus.REFUND_FAILED.value self.db.database.transfers.update_one( {"incoming_tx_id": incoming_trx_id}, {"$set": { "status": status }}, ) def process_transfer(self, transaction_data): op = transaction_data["op"][1] amount = Amount(op["amount"]) # check if transaction is already registered in the database if self.db.is_transfer_already_registered(transaction_data["trx_id"]): logger.info( "Transaction is already registered. Skipping. (TRX: %s)", transaction_data["trx_id"]) return # register the transaction into db self.db.register_incoming_transaction( op["from"], op["memo"], transaction_data["block"], transaction_data["trx_id"], ) # check if the asset is valid if amount.symbol != "STEEM": logger.info( "Invalid asset. Refunding %s with %s. (TRX: %s)", op["from"], amount, transaction_data["trx_id"], ) self.refund( op["from"], op["amount"], RefundReason.INVALID_ASSET.value, transaction_data["trx_id"], ) return # check if the VP is suitable # Check the VP acc = self.client.account(self.account) if acc.vp() < self.minimum_vp_to_vote: print(acc.vp(), self.minimum_vp_to_vote) logger.info("Rando is sleeping. Refunding. (TRX: %s)", transaction_data["trx_id"]) self.refund( op["from"], op["amount"], RefundReason.SLEEP_MODE.value, transaction_data["trx_id"], ) return # check vote price if amount.amount != self.vote_price.amount: logger.info("Invalid amount. Refunding. (TRX: %s)", transaction_data["trx_id"]) self.refund( op["from"], op["amount"], "Invalid amount. You need to send %s" % self.vote_price, transaction_data["trx_id"], ) return # check if the sender is in a blacklist if self.in_global_blacklist(op["from"]): logger.info("Sender is in blacklist. Refunding. (TRX: %s)", transaction_data["trx_id"]) self.refund( op["from"], op["amount"], RefundReason.SENDER_IN_BLACKLIST.value, transaction_data["trx_id"], ) return # check if the memo is valid memo = op["memo"] try: author = memo.split("@")[1].split("/")[0] permlink = memo.split("@")[1].split("/")[1] except IndexError as e: logger.info("Invalid URL. Refunding. Memo: %s", memo) self.refund( op["from"], op["amount"], RefundReason.INVALID_URL.value, transaction_data["trx_id"], ) return # check if the author is in a blacklist if self.in_global_blacklist(author): logger.info("Author is in blacklist. Refunding. (TRX: %s)", transaction_data["trx_id"]) self.refund( op["from"], op["amount"], RefundReason.AUTHOR_IN_BLACKLIST.value, transaction_data["trx_id"], ) return # check is the Comment is a valid Comment comment_content = self.get_content(author, permlink) if comment_content["id"] == 0: logger.info("Invalid post. Refunding. (TRX id: %s)", transaction_data["trx_id"]) self.refund( op["from"], op["amount"], RefundReason.INVALID_URL.value, transaction_data["trx_id"], ) return # check comment is valid if comment_content.get("parent_author"): logger.info("Not a main post. Refunding. (TRX id: %s)", transaction_data["trx_id"]) self.refund( op["from"], op["amount"], RefundReason.INVALID_URL.value, transaction_data["trx_id"], ) return # check if we've already voted for that post active_voters = self.get_active_votes(author, permlink) if self.account in active_voters: logger.info("Already voted. Refunding. (TRX id: %s)", transaction_data["trx_id"]) self.refund( op["from"], op["amount"], RefundReason.POST_IS_ALREADY_VOTED.value, transaction_data["trx_id"], ) return # check if the Comment age is suitable created = parse(comment_content["created"]) comment_age = (datetime.now() - created).total_seconds() if not (self.min_age_to_vote < comment_age < self.max_age_to_vote): self.refund( op["from"], op["amount"], f"Post age must be between {self.min_age_to_vote} and" f" {self.max_age_to_vote} seconds", transaction_data["trx_id"], ) logger.info("Post age is invalid. Refunding. (TRX id: %s)", transaction_data["trx_id"]) return # vote self.vote(author, permlink, amount, transaction_data) def vote(self, author, permlink, amount, transaction_data): random_vote_weight = self.vote_percent.pick_percent() logger.info("Voting for %s/%s. Random vote weight: %%%s", author, permlink, random_vote_weight) vote_op = Operation( 'vote', { "voter": self.account, "author": author, "permlink": permlink, "weight": random_vote_weight * 100, }) burn_op = Operation( 'transfer', { "from": self.account, "to": "null", "amount": str(amount), "memo": f"Burning STEEM for {author}/{permlink}", }) # vote try: self.vote_client.broadcast(vote_op) time.sleep(3) status = TransferStatus.VOTED.value except Exception as e: print(e) status = TransferStatus.VOTE_FAILED.value # burn try: self.refund_client.broadcast(burn_op) time.sleep(3) except Exception as e: print(e) status = TransferStatus.BURN_FAILED.value self.db.database.transfers.update_one( {"incoming_tx_id": transaction_data["trx_id"]}, {"$set": { "status": status }}, )
class TestAccountHelper(unittest.TestCase): def setUp(self): self.client = Client(nodes=TestClient.NODES) def test_vp_with_hf20(self): last_vote_time = datetime.datetime.utcnow() - datetime.timedelta( hours=24) utc = pytz.timezone('UTC') last_vote_time = utc.localize(last_vote_time) result = { 'voting_manabar': { 'current_mana': 7900, 'last_update_time': int(last_vote_time.timestamp()) } } with requests_mock.mock() as m: m.post(TestClient.NODES[0], json={"result": [result]}) account = self.client.account('emrebeyler') self.assertEqual(99, account.vp()) def test_vp(self): last_vote_time = datetime.datetime.utcnow() - datetime.timedelta( hours=24) result = { "last_vote_time": last_vote_time.strftime("%Y-%m-%dT%H:%M:%S"), "voting_power": 7900, } with requests_mock.mock() as m: m.post(TestClient.NODES[0], json={"result": [result]}) account = self.client.account('emrebeyler') self.assertEqual(99.0, account.vp()) def test_rc(self): def match_get_accounts(request): method = json.loads(request.text)["method"] return method == "condenser_api.get_accounts" def match_find_rc_accounts(request): method = json.loads(request.text)["method"] return method == "rc_api.find_rc_accounts" last_update_time = datetime.datetime.utcnow() - datetime.timedelta( hours=24) last_update_timestamp = last_update_time.replace( tzinfo=datetime.timezone.utc).timestamp() result = { "rc_accounts": [{ 'account': 'emrebeyler', 'rc_manabar': { 'current_mana': '750', 'last_update_time': last_update_timestamp }, 'max_rc_creation_adjustment': { 'amount': '1029141630', 'precision': 6, 'nai': '@@000000037' }, 'max_rc': '1000' }] } with requests_mock.mock() as m: m.post(TestClient.NODES[0], json={"result": [{ "foo": "bar" }]}, additional_matcher=match_get_accounts) m.post(TestClient.NODES[0], json={"result": result}, additional_matcher=match_find_rc_accounts) self.assertEqual(float(95), self.client.account('emrebeyler').rc()) self.assertEqual( float(75), self.client.account('emrebeyler').rc( consider_regeneration=False)) def test_reputation(self): reputation_sample = '74765490672156' # 68.86 with requests_mock.mock() as m: m.post(TestClient.NODES[0], json={"result": [{ "reputation": reputation_sample }]}) account = self.client.account('emrebeyler') self.assertEqual(68.86, account.reputation()) def test_account_history_simple(self): def match_max_index_request(request): params = json.loads(request.text)["params"] return params[1] == -1 def match_non_max_index_request(request): params = json.loads(request.text)["params"] return params[1] != -1 with requests_mock.mock() as m: m.post(TestClient.NODES[0], json={"result": mock_history_max_index}, additional_matcher=match_max_index_request) m.post(TestClient.NODES[0], json={"result": mock_history}, additional_matcher=match_non_max_index_request) account = Account(self.client) history = list(account.history(account="hellosteem")) self.assertEqual(3, len(history)) # check filter history = list( account.history(account="hellosteem", filter=["transfer"])) self.assertEqual(2, len(history)) # check exclude history = list( account.history(account="hellosteem", exclude=["transfer"])) self.assertEqual(1, len(history)) # check only_operation_data history = list( account.history(account="hellosteem", only_operation_data=False)) self.assertEqual(3, history[0][0])