From a4327a8716daa4265af01f681574726f70a90d75 Mon Sep 17 00:00:00 2001 From: alfred richardsn Date: Wed, 10 Jun 2020 21:44:31 +0300 Subject: [PATCH 1/3] Change MARKDOWN to MARKDOWN_V2 Markdown is now legacy parse mode and was superseded by MarkdownV2 (as per Telegram bot API: https://core.telegram.org/bots/api#markdownv2-style). This change is reflected in aiogram's quote function, so in order to not add reduntant escape characters, it had to be updated. It also allows nested, underline and strikethrough entities, which may be nice in some future. Unfortunately, it means that a whole lot more characters now must be escaped. These characters: >#+-=|{}.! are not used in markup as of now but are still reserved and require escaping in any place of the message for some reason. For instance, .! reservation means that even translation strings themselves that have punctuation now have to be escaped when used in markdown, because formatter's field values are often should not be escaped (e. g. user links). Meanwhile, _{} reservation means that formatter's placeholders will be escaped with aiogram's markdown.escape_md function. So as a solution I've added an optional argument escape_md to i18n which escaped translation string but rolls back placeholders as they were afterwards. All in all, the style update is a mixed bag. There are some new features which can be useful, but it complicates string construction as reserved characters should be watched much more closely, and some choices for markup characters (in particular, punctuation marks . and !) are questionable to say the least. Signed-off-by: alfred richardsn --- src/escrow/blockchain/__init__.py | 36 +++++++----- src/handlers/__init__.py | 4 +- src/handlers/base.py | 33 +++++++---- src/handlers/escrow.py | 98 +++++++++++++++++++------------ src/handlers/order.py | 3 +- src/handlers/support.py | 10 ++-- src/i18n.py | 14 +++++ src/notifications.py | 10 +++- 8 files changed, 128 insertions(+), 80 deletions(-) diff --git a/src/escrow/blockchain/__init__.py b/src/escrow/blockchain/__init__.py index 00ce46d..8459577 100644 --- a/src/escrow/blockchain/__init__.py +++ b/src/escrow/blockchain/__init__.py @@ -240,9 +240,11 @@ async def _confirmation_callback( escrow_user = offer["counter"] other_user = offer["init"] - answer = i18n( - "transaction_passed {currency}", locale=escrow_user["locale"] - ).format(currency=offer[new_currency]) + answer = markdown.escape_md( + i18n("transaction_passed {currency}", locale=escrow_user["locale"]).format( + currency=offer[new_currency] + ) + ) await tg.send_message(escrow_user["id"], answer) is_confirmed = await create_task(self.is_block_confirmed(block_num, op)) if is_confirmed: @@ -260,19 +262,21 @@ async def _confirmation_callback( i18n("transaction_confirmed", locale=other_user["locale"]), self.trx_url(trx_id), ) - answer += "\n" + i18n( - "send {amount} {currency} {address}", locale=other_user["locale"] - ).format( - amount=offer[f"sum_{new_currency}"], - currency=offer[new_currency], - address=markdown.escape_md(escrow_user["receive_address"]), + answer += "\n" + markdown.escape_md( + i18n( + "send {amount} {currency} {address}", locale=other_user["locale"] + ).format( + amount=offer[f"sum_{new_currency}"], + currency=offer[new_currency], + address=escrow_user["receive_address"], + ) ) - answer += "." + answer += r"\." await tg.send_message( other_user["id"], answer, reply_markup=keyboard, - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, ) return True @@ -327,7 +331,7 @@ async def _refund_callback( answer += f"\n• {message_point}" answer += "\n\n" + i18n("refund_promise", locale=user["locale"]) - await tg.send_message(user["id"], answer, parse_mode=ParseMode.MARKDOWN) + await tg.send_message(user["id"], answer) is_confirmed = await create_task(self.is_block_confirmed(block_num, op)) await database.escrow.update_one( {"_id": offer["_id"]}, {"$set": {"transaction_time": time()}} @@ -343,9 +347,11 @@ async def _refund_callback( i18n("transaction_refunded", locale=user["locale"]), trx_url ) else: - answer = i18n("transaction_not_confirmed", locale=user["locale"]) - answer += " " + i18n("try_again", locale=user["locale"]) - await tg.send_message(user["id"], answer, parse_mode=ParseMode.MARKDOWN) + answer = i18n( + "transaction_not_confirmed", locale=user["locale"], escape_md=True + ) + answer += " " + i18n("try_again", locale=user["locale"], escape_md=True) + await tg.send_message(user["id"], answer, parse_mode=ParseMode.MARKDOWN_V2) class StreamBlockchain(BaseBlockchain): diff --git a/src/handlers/__init__.py b/src/handlers/__init__.py index 3841a62..6b988bd 100644 --- a/src/handlers/__init__.py +++ b/src/handlers/__init__.py @@ -86,7 +86,7 @@ async def errors_handler(update: types.Update, exception: Exception): else: await tg.send_message( exceptions_chat_id, - "Error handling {} {} from {} ({}) in chat {}\n{}".format( + r"Error handling {} {} from {} \({}\) in chat {}\n{}".format( update_type, update.update_id, markdown.link(from_user.mention, from_user.url), @@ -94,7 +94,7 @@ async def errors_handler(update: types.Update, exception: Exception): chat_id, markdown.code(traceback.format_exc(limit=-3)), ), - parse_mode=types.ParseMode.MARKDOWN, + parse_mode=types.ParseMode.MARKDOWN_V2, ) await tg.send_message( chat_id, i18n("unexpected_error"), reply_markup=start_keyboard(), diff --git a/src/handlers/base.py b/src/handlers/base.py index baa02ba..964cf31 100644 --- a/src/handlers/base.py +++ b/src/handlers/base.py @@ -175,10 +175,12 @@ async def orders_list( order["buy"], ) + line = markdown.escape_md(line) + if user_id is not None and order["user_id"] == user_id: line = f"*{line}*" - lines.append(f"{i + 1}. {line}") + lines.append(fr"{i + 1}\. {line}") buttons.append( types.InlineKeyboardButton( "{}".format(i + 1), callback_data="get_order {}".format(order["_id"]) @@ -194,22 +196,21 @@ async def orders_list( keyboard.add(*buttons) keyboard.row(*inline_orders_buttons) - text = ( - "\\[" + text = markdown.escape_md( + "[" + i18n("page {number} {total}").format( number=math.ceil(start / config.ORDERS_COUNT) + 1, total=math.ceil(quantity / config.ORDERS_COUNT), ) + "]\n" - + "\n".join(lines) - ) + ) + "\n".join(lines) if message_id is None: await tg.send_message( chat_id, text, reply_markup=keyboard, - parse_mode=types.ParseMode.MARKDOWN, + parse_mode=types.ParseMode.MARKDOWN_V2, disable_web_page_preview=True, ) else: @@ -218,7 +219,7 @@ async def orders_list( chat_id, message_id, reply_markup=keyboard, - parse_mode=types.ParseMode.MARKDOWN, + parse_mode=types.ParseMode.MARKDOWN_V2, disable_web_page_preview=True, ) @@ -305,7 +306,7 @@ async def show_order( header += markdown.bold(i18n("archived", locale=locale)) + "\n" creator = await database.users.find_one({"id": order["user_id"]}) - header += "{} ({}) ".format( + header += r"{} \({}\) ".format( markdown.link(creator["mention"], types.User(id=creator["id"]).url), markdown.code(creator["id"]), ) @@ -313,9 +314,13 @@ async def show_order( act = i18n("sells {sell_currency} {buy_currency}", locale=locale) else: act = i18n("buys {buy_currency} {sell_currency}", locale=locale) - header += act.format(buy_currency=order["buy"], sell_currency=order["sell"]) + "\n" + header += ( + markdown.escape_md( + act.format(buy_currency=order["buy"], sell_currency=order["sell"]) + ) + + "\n" + ) - lines = [header] field_names = get_order_field_names() lines_format: typing.Dict[str, typing.Optional[str]] = {} for name in field_names: @@ -358,6 +363,8 @@ async def show_order( ) ) + lines = [] + if edit and creator["id"] == user_id: buttons = [] for i, (field, value) in enumerate(lines_format.items()): @@ -452,7 +459,7 @@ async def show_order( ) ) - answer = "\n".join(lines) + answer = "\n".join((header, *map(markdown.escape_md, lines))) if message_id is not None: await tg.edit_message_text( @@ -460,7 +467,7 @@ async def show_order( chat_id, message_id, reply_markup=keyboard, - parse_mode=types.ParseMode.MARKDOWN, + parse_mode=types.ParseMode.MARKDOWN_V2, disable_web_page_preview=True, ) if new_edit_msg is not None: @@ -479,6 +486,6 @@ async def show_order( chat_id, answer, reply_markup=keyboard, - parse_mode=types.ParseMode.MARKDOWN, + parse_mode=types.ParseMode.MARKDOWN_V2, disable_web_page_preview=True, ) diff --git a/src/handlers/escrow.py b/src/handlers/escrow.py index 098848c..f1207da 100644 --- a/src/handlers/escrow.py +++ b/src/handlers/escrow.py @@ -292,21 +292,24 @@ async def ask_credentials( i18n( "request_full_card_number {currency} {user}", locale=request_user["locale"], - ).format(currency=currency, user=mention), + escape_md=True, + ).format(currency=markdown.escape_md(currency), user=mention), reply_markup=keyboard, - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, ) state = FSMContext(dp.storage, request_user["id"], request_user["id"]) await state.set_state(states.Escrow.full_card.state) answer = i18n( - "asked_full_card_number {user}", locale=answer_user["locale"] + "asked_full_card_number {user}", + locale=answer_user["locale"], + escape_md=True, ).format( user=markdown.link( request_user["mention"], User(id=request_user["id"]).url ) ) await tg.send_message( - answer_user["id"], answer, parse_mode=ParseMode.MARKDOWN, + answer_user["id"], answer, parse_mode=ParseMode.MARKDOWN_V2, ) return @@ -385,8 +388,10 @@ async def full_card_number_message(message: types.Message, offer: EscrowOffer): mention = markdown.link(user["mention"], User(id=user["id"]).url) await tg.send_message( message.chat.id, - i18n("wrong_full_card_number_receiver {user}").format(user=mention), - parse_mode=ParseMode.MARKDOWN, + i18n("wrong_full_card_number_receiver {user}", escape_md=True).format( + user=mention + ), + parse_mode=ParseMode.MARKDOWN_V2, ) @@ -404,10 +409,10 @@ async def full_card_number_sent(call: types.CallbackQuery, offer: EscrowOffer): ) await tg.send_message( call.message.chat.id, - i18n("exchange_continued {user}").format( + i18n("exchange_continued {user}", escape_md=True).format( user=markdown.link(counter["mention"], User(id=counter["id"]).url) ), - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, ) await offer.update_document({"$set": {"pending_input_from": counter["id"]}}) counter_state = FSMContext(dp.storage, counter["id"], counter["id"]) @@ -579,23 +584,28 @@ async def set_init_send_address( "escrow_offer_notification {user} {sell_amount} {sell_currency} " "for {buy_amount} {buy_currency}", locale=locale, + escape_md=True, ).format( user=mention, - sell_amount=offer.sum_sell, - sell_currency=offer.sell, - buy_amount=offer.sum_buy, - buy_currency=offer.buy, + sell_amount=markdown.escape_md(offer.sum_sell), + sell_currency=markdown.escape_md(offer.sell), + buy_amount=markdown.escape_md(offer.sum_buy), + buy_currency=markdown.escape_md(offer.buy), ) if offer.bank: - answer += " " + i18n("using {bank}", locale=locale).format(bank=offer.bank) - answer += "." + answer += " " + markdown.escape_md( + i18n("using {bank}", locale=locale).format(bank=offer.bank) + ) + answer += r"\." update_dict = {"init.send_address": address} if offer.type == "sell": insured = await get_insurance(offer) update_dict["insured"] = Decimal128(insured) if offer[f"sum_{offer.type}"].to_decimal() > insured: - answer += "\n" + i18n("exceeded_insurance {amount} {currency}").format( - amount=insured, currency=offer.escrow + answer += "\n" + markdown.escape_md( + i18n("exceeded_insurance {amount} {currency}", escape_md=True).format( + amount=insured, currency=offer.escrow + ) ) await offer.update_document( {"$set": update_dict, "$unset": {"pending_input_from": True}} @@ -604,7 +614,7 @@ async def set_init_send_address( offer.counter["id"], answer, reply_markup=buy_keyboard, - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, ) sell_keyboard = InlineKeyboardMarkup() sell_keyboard.add( @@ -746,12 +756,21 @@ async def set_counter_send_address( ) escrow_address = markdown.bold(escrow_instance.address) answer = i18n( - "send {amount} {currency} {address}", locale=escrow_user["locale"] - ).format(amount=offer.sum_fee_up, currency=offer.escrow, address=escrow_address) - answer += " " + i18n("with_memo", locale=escrow_user["locale"]) + "send {amount} {currency} {address}", + locale=escrow_user["locale"], + escape_md=True, + ).format( + amount=markdown.escape_md(offer.sum_fee_up), + currency=markdown.escape_md(offer.escrow), + address=escrow_address, + ) + answer += " " + i18n("with_memo", locale=escrow_user["locale"], escape_md=True) answer += ":\n" + markdown.code(memo) await tg.send_message( - escrow_user["id"], answer, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN + escrow_user["id"], + answer, + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN_V2, ) if send_reply: keyboard = InlineKeyboardMarkup() @@ -766,7 +785,6 @@ async def set_counter_send_address( + " " + i18n("transaction_completion_notification_promise"), reply_markup=keyboard, - parse_mode=ParseMode.MARKDOWN, ) update = { "counter.send_address": address, @@ -887,34 +905,34 @@ async def final_offer_confirmation(call: types.CallbackQuery, offer: EscrowOffer async def complete_offer(call: types.CallbackQuery, offer: EscrowOffer): """Release escrow asset and finish exchange.""" if offer.type == "buy": - recipient_user = offer.counter + rcvr_user = offer.counter other_user = offer.init elif offer.type == "sell": - recipient_user = offer.init + rcvr_user = offer.init other_user = offer.counter await call.answer(i18n("escrow_completing")) escrow_instance = get_escrow_instance(offer.escrow) trx_url = await escrow_instance.transfer( - recipient_user["receive_address"], + rcvr_user["receive_address"], offer.sum_fee_down.to_decimal(), # type: ignore offer.escrow, memo=create_memo(offer, transfer=True), ) - answer = i18n("escrow_completed", locale=other_user["locale"]) - recipient_answer = i18n("escrow_completed", locale=recipient_user["locale"]) - recipient_answer += " " + markdown.link( - i18n("escrow_sent {amount} {currency}", locale=recipient_user["locale"]).format( + answer = i18n("escrow_completed", locale=other_user["locale"], escape_md=True) + rcvr_answer = i18n("escrow_completed", locale=rcvr_user["locale"], escape_md=True) + rcvr_answer += " " + markdown.link( + i18n("escrow_sent {amount} {currency}", locale=rcvr_user["locale"]).format( amount=offer.sum_fee_down, currency=offer.escrow ), trx_url, ) await offer.delete_document() await tg.send_message( - recipient_user["id"], - recipient_answer, + rcvr_user["id"], + rcvr_answer, reply_markup=start_keyboard(), - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, ) await tg.send_message(other_user["id"], answer, reply_markup=start_keyboard()) @@ -924,26 +942,28 @@ async def validate_offer(call: types.CallbackQuery, offer: EscrowOffer): """Ask support for manual verification of exchange.""" if offer.type == "buy": sender = offer.counter - receiver = offer.init + rcvr = offer.init currency = offer.sell elif offer.type == "sell": sender = offer.init - receiver = offer.counter + rcvr = offer.counter currency = offer.buy escrow_instance = get_escrow_instance(offer.escrow) answer = "{0}\n{1} sender: {2}{3}\n{1} receiver: {4}{5}\nBank: {6}\nMemo: {7}" answer = answer.format( markdown.link("Unconfirmed escrow.", escrow_instance.trx_url(offer.trx_id)), - currency, + markdown.escape_md(currency), markdown.link(sender["mention"], User(id=sender["id"]).url), - " ({})".format(sender["name"]) if "name" in sender else "", - markdown.link(receiver["mention"], User(id=receiver["id"]).url), - " ({})".format(receiver["name"]) if "name" in receiver else "", + markdown.escape_md(" ({})".format(sender["name"])) if "name" in sender else "", + markdown.link(rcvr["mention"], User(id=rcvr["id"]).url), + markdown.escape_md(" ({})".format(rcvr["name"])) if "name" in rcvr else "", offer.bank, markdown.code(offer.memo), ) - await tg.send_message(config.SUPPORT_CHAT_ID, answer, parse_mode=ParseMode.MARKDOWN) + await tg.send_message( + config.SUPPORT_CHAT_ID, answer, parse_mode=ParseMode.MARKDOWN_V2 + ) await offer.delete_document() await call.answer() await tg.send_message( diff --git a/src/handlers/order.py b/src/handlers/order.py index 512a28a..0bd8051 100644 --- a/src/handlers/order.py +++ b/src/handlers/order.py @@ -387,6 +387,7 @@ async def edit_button(call: types.CallbackQuery): keyboard = types.InlineKeyboardMarkup() unset_button = types.InlineKeyboardButton(i18n("unset"), callback_data="unset") + answer = None if field == "sum_buy": answer = i18n("send_new_buy_amount") keyboard.row(unset_button) @@ -421,8 +422,6 @@ async def edit_button(call: types.CallbackQuery): elif field == "comments": answer = i18n("send_new_comments") keyboard.row(unset_button) - else: - answer = None await call.answer() if not answer: diff --git a/src/handlers/support.py b/src/handlers/support.py index 502cd78..2d3279d 100644 --- a/src/handlers/support.py +++ b/src/handlers/support.py @@ -47,17 +47,15 @@ async def send_message_to_support(message: types.Message): Envelope emoji at the beginning is the mark of support ticket. """ - if message.from_user.username: - username = "@" + message.from_user.username - else: - username = markdown.link(message.from_user.full_name, message.from_user.url) + mention = markdown.link(message.from_user.mention, message.from_user.url) await tg.send_message( config.SUPPORT_CHAT_ID, emojize(":envelope:") - + f" #chat\\_{message.chat.id} {message.message_id}\n{username}:\n" + + markdown.escape_md(f" #chat_{message.chat.id} {message.message_id}\n") + + f"{mention}:\n" + markdown.escape_md(message.text), - parse_mode=types.ParseMode.MARKDOWN, + parse_mode=types.ParseMode.MARKDOWN_V2, ) await tg.send_message( message.chat.id, diff --git a/src/i18n.py b/src/i18n.py index 8ecb1be..a0faa36 100644 --- a/src/i18n.py +++ b/src/i18n.py @@ -14,11 +14,13 @@ # # You should have received a copy of the GNU Affero General Public License # along with TellerBot. If not, see . +import re import typing from pathlib import Path from aiogram import types from aiogram.contrib.middlewares.i18n import I18nMiddleware +from aiogram.utils import markdown from pymongo import ReturnDocument from src.database import database @@ -35,6 +37,18 @@ def __init__(self, domain, path, default="en"): self.path = path self.default = default + def __call__(self, *args, escape_md=False, **kwargs) -> str: + """Call I18nMiddleware with option to escape markdown retaining formatting.""" + translation = super().__call__(*args, **kwargs) + if escape_md: + placeholders = re.findall(r"{\w*}", translation) + text_parts = map(markdown.escape_md, re.split(r"{\w*}", translation)) + first_part = next(text_parts) + rest_generator = (p + tp for p, tp in zip(placeholders, text_parts)) + return "".join((first_part, *rest_generator)) + else: + return translation + async def get_user_locale( self, action: str, args: typing.Tuple[typing.Any] ) -> typing.Optional[str]: diff --git a/src/notifications.py b/src/notifications.py index 1e7b4a9..db77810 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -18,6 +18,8 @@ import typing from time import time +from aiogram.types import ParseMode +from aiogram.utils import markdown from aiogram.utils.exceptions import TelegramAPIError from src.bot import tg @@ -36,12 +38,14 @@ async def run_loop(): sent = False async for order in cursor: user = await database.users.find_one({"id": order["user_id"]}) - message = i18n("order_expired", locale=user["locale"]) - message += "\nID: {}".format(order["_id"]) + message = i18n("order_expired", locale=user["locale"], escape_md=True) + message += "\nID: {}".format(markdown.code(order["_id"])) try: if sent: await asyncio.sleep(1) # Avoid Telegram limit - await tg.send_message(user["chat"], message) + await tg.send_message( + user["chat"], message, parse_mode=ParseMode.MARKDOWN_V2 + ) except TelegramAPIError: pass else: From 3d991a343b86e55dac7616e3e372fe0781081629 Mon Sep 17 00:00:00 2001 From: alfred richardsn Date: Thu, 11 Jun 2020 19:46:22 +0300 Subject: [PATCH 2/3] Bump aiogram from 2.8 to 2.9 (aiogram/aiogram@557147a) There is a bug in [aiogram](https://github.com/aiogram/aiogram) 2.8 which quotes markdown values as html and tagged 2.9 release outright breaks markdown helpers. It was fixed a couple of days ago in a referenced commit, so bot will use that version of aiogram as a temporary measurement until it will be tagged. Signed-off-by: alfred richardsn --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ac0fbe1..a0cb348 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -aiogram==2.8 emoji==0.5.4 +git+https://github.com/aiogram/aiogram.git@557147ad8d39ec6d90f36a840c09b458a063e48f motor==2.1.0 pymongo==3.10.1 requests==2.23.0 From 164b20e37678daf9f0c7889ff6131a55dee69ee4 Mon Sep 17 00:00:00 2001 From: alfred richardsn Date: Thu, 11 Jun 2020 20:09:01 +0300 Subject: [PATCH 3/3] Fix bot ID retrieval from token Signed-off-by: alfred richardsn --- src/bot.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/bot.py b/src/bot.py index 6ed5cd6..edb4942 100644 --- a/src/bot.py +++ b/src/bot.py @@ -20,6 +20,7 @@ from aiogram import Bot from aiogram import types +from aiogram.bot import api from aiogram.contrib.middlewares.logging import LoggingMiddleware from aiogram.dispatcher import Dispatcher @@ -28,14 +29,17 @@ from src.i18n import i18n -tg = Bot(None, loop=asyncio.get_event_loop(), validate_token=False) +tg = Bot("0:", loop=asyncio.get_event_loop(), validate_token=False) dp = Dispatcher(tg) def setup(): """Set API token from config to bot and setup dispatcher.""" with open(config.TOKEN_FILENAME, "r") as token_file: - tg._ctx_token.set(token_file.read().strip()) + token = token_file.read().strip() + api.check_token(token) + tg._ctx_token.set(token) + tg.id = int(token.split(":")[0]) dp.storage = MongoStorage()