def on_pack_name(update: Update, context: CallbackContext): logger.info('user selected the pack name from the keyboard') logger.debug('user_data: %s', context.user_data) if re.search(r'^GO BACK$', update.message.text, re.I): with session_scope() as session: pack_titles = [t.title for t in session.query(Pack.title).filter_by(user_id=update.effective_user.id).all()] markup = Keyboard.from_list(pack_titles) update.message.reply_text(Strings.ADD_STICKER_SELECT_PACK, reply_markup=markup) return Status.WAITING_TITLE # the buttons list has the name without "_by_botusername" selected_name = '{}_by_{}'.format(update.message.text, context.bot.username) with session_scope() as session: pack = session.query(Pack).filter_by(name=selected_name, user_id=update.effective_user.id).first().name pack_name = pack.name pack_animated = pack.is_animated if not pack_name: logger.error('user %d does not have any pack with name %s', update.effective_user.id, selected_name) update.message.reply_text(Strings.ADD_STICKER_SELECTED_NAME_DOESNT_EXIST) # do not reset the user status return Status.WAITING_NAME logger.info('selected pack is animated: %s', pack_animated) context.user_data['pack'] = dict(name=pack_name, animated=pack_animated) pack_link = utils.name2link(pack_name) base_string = Strings.ADD_STICKER_PACK_SELECTED_STATIC if not pack_animated else Strings.ADD_STICKER_PACK_SELECTED_ANIMATED update.message.reply_html(base_string.format(pack_link), reply_markup=Keyboard.HIDE) return Status.WAITING_STATIC_STICKERS
def on_cleanup_command(update: Update, context: CallbackContext): logger.info('/cleanup') # packs = db.get_user_packs(update.effective_user.id, as_namedtuple=True) with session_scope() as session: packs = session.query(Pack).filter_by( user_id=update.effective_user.id).all() packs = [(p.title, p.name, p.is_animated) for p in packs] if not packs: update.message.reply_text(Strings.LIST_NO_PACKS) return update.message.reply_html(Strings.CLEANUP_WAIT) packs_to_delete = list() for pack in packs: logger.debug('checking pack: %s', pack[1]) request_payload = dict(user_id=update.effective_user.id, name=pack[1]) try: context.bot.get_sticker_set(**request_payload) except TelegramError as telegram_error: if telegram_error.message == 'Stickerset_invalid': logger.debug('this pack will be removed from the db (%s)', telegram_error.message) packs_to_delete.append( pack) # list of packs we will have to delete else: logger.debug('api exception: %s', telegram_error.message) if not packs_to_delete: update.message.reply_text(Strings.CLEANUP_NO_PACK) return with session_scope() as session: for _, pack_name, _ in packs_to_delete: logger.info('deleting pack from db...') session.query(Pack).filter( Pack.user_id == update.effective_user.id, Pack.name == pack_name).delete() logger.info('done') packs_links = [ '<a href="{}">{}</a>'.format(utils.name2link(pack[1]), pack[0]) for pack in packs_to_delete ] update.message.reply_html(Strings.CLEANUP_HEADER + '• {}'.format('\n• '.join(packs_links))) return ConversationHandler.END # /cleanup should end whatever conversation the user was having
def on_forgetme_command(update: Update, _): logger.info('/forgetme') with session_scope() as session: deleted_rows = session.query(Pack).filter(Pack.user_id==update.effective_user.id).delete() logger.info('deleted rows: %d', deleted_rows or 0) update.message.reply_text(Strings.FORGETME_SUCCESS)
def on_forgetme_command(update: Update, _): logger.info('/forgetme') with session_scope() as session: deleted_rows = session.query(Pack).filter( Pack.user_id == update.effective_user.id).delete() logger.info('deleted rows: %d', deleted_rows or 0) update.message.reply_text(Strings.FORGETME_SUCCESS) return ConversationHandler.END # /forgetme should end whatever conversation the user was having
def check_pack_name(user_id: int, pack_name: str, context: CallbackContext) -> [None, str]: pack_link = utils.name2link(pack_name, context.bot.username) if not re.search(r"^[a-z0-9][a-z0-9_]+[a-z0-9]$", pack_name, re.I): # needs to be improved return Strings.READD_INVALID_PACK_NAME_PATTERN if not pack_name.endswith(PACK_SUFFIX): return Strings.READD_WRONG_PACK_NAME.format(pack_link, PACK_SUFFIX) with session_scope() as session: pack_exists = session.query(Pack).filter_by(name=pack_name, user_id=user_id).first() is not None if pack_exists: return Strings.READD_PACK_EXISTS.format(pack_link)
def on_list_command(update: Update, _): logger.info('/list') # packs = db.get_user_packs(update.effective_user.id, as_namedtuple=True) with session_scope() as session: packs = session.query(Pack).filter_by(user_id=update.effective_user.id).all() packs = packs[:100] # can't include more than 100 entities strings_list = ['<a href="{}">{}</a>'.format(utils.name2link(pack.name), pack.title) for pack in packs] if not strings_list: update.message.reply_text(Strings.LIST_NO_PACKS) return update.message.reply_html('• {}'.format('\n• '.join(strings_list)))
def on_add_command(update: Update, _): logger.info('/add') with session_scope() as session: pack_titles = [t.title for t in session.query(Pack.title).filter_by(user_id=update.effective_user.id).all()] if not pack_titles: update.message.reply_text(Strings.ADD_STICKER_NO_PACKS) return ConversationHandler.END else: markup = Keyboard.from_list(pack_titles) update.message.reply_text(Strings.ADD_STICKER_SELECT_PACK, reply_markup=markup) return Status.WAITING_TITLE
def on_list_command(update: Update, _): logger.info('/list') # packs = db.get_user_packs(update.effective_user.id, as_namedtuple=True) with session_scope() as session: packs = session.query(Pack).filter_by(user_id=update.effective_user.id).order_by(Pack.title).all() packs = packs[:98] # can't include more than 100 entities strings_list = ['<a href="{}">{}</a> ({})'.format(utils.name2link(pack.name), pack.title, 'a' if pack.is_animated else 's') for pack in packs] if not strings_list: update.message.reply_text(Strings.LIST_NO_PACKS) return update.message.reply_html('• {}'.format('\n• '.join(strings_list)) + Strings.LIST_FOOTER) return ConversationHandler.END # /list should end whatever conversation the user was having
def on_pack_name_receive(update: Update, context: CallbackContext): logger.info('received possible pack name (link)') logger.debug('user_data: %s', context.user_data) candidate_name = update.message.text max_name_len = 64 - (len(context.bot.username) + 4) if len(candidate_name) > max_name_len: logger.info('pack name too long (%d/%d)', len(candidate_name), max_name_len) update.message.reply_text( Strings.PACK_NAME_TOO_LONG.format(len(update.message.text), max_name_len)) # do not change the user status and let him send another name return Status.CREATE_WAITING_NAME if not re.search(r'^[a-z](?!__)\w+$', candidate_name, re.IGNORECASE): logger.info('pack name not valid: %s', update.message.text) update.message.reply_html(Strings.PACK_NAME_INVALID) # do not change the user status and let him send another name return Status.CREATE_WAITING_NAME name_already_used = False with session_scope() as session: # https://stackoverflow.com/a/34112760 if session.query(Pack).filter( Pack.user_id == update.effective_user.id, Pack.name == candidate_name).first() is not None: logger.info('pack name already saved: %s', candidate_name) name_already_used = True if name_already_used: update.message.reply_text(Strings.PACK_NAME_DUPLICATE) # do not change the user status and let him send another name return Status.CREATE_WAITING_NAME logger.info('valid pack name: %s', candidate_name) context.user_data['pack']['name'] = candidate_name if context.user_data['pack']['animated']: text = Strings.PACK_CREATION_WAITING_FIRST_ANIMATED_STICKER else: text = Strings.PACK_CREATION_WAITING_FIRST_STATIC_STICKER update.message.reply_text(text) return Status.CREATE_WAITING_FIRST_STICKER
def on_pack_title(update: Update, context: CallbackContext): logger.info('user selected the pack title from the keyboard') selected_title = update.message.text user_id = update.effective_user.id with session_scope() as session: packs_by_title = session.query(Pack).filter_by(title=selected_title, user_id=user_id).order_by(Pack.name).all() # for some reason, accessing a Pack attribute outside of a session # raises an error: https://docs.sqlalchemy.org/en/13/errors.html#object-relational-mapping # so we preload the list here in case we're going to need it later, to avoid a more complex handling # of the session by_bot_part = '_by_' + context.bot.username pack_names = [pack.name.replace(by_bot_part, '', 1) for pack in packs_by_title] # strip the '_by_bot' part pack_animated = packs_by_title[0].is_animated # we need this in case there's only one pack and we need to know whether it is animated or not if not packs_by_title: logger.error('cannot find any pack with this title: %s', selected_title) update.message.reply_text(Strings.ADD_STICKER_SELECTED_TITLE_DOESNT_EXIST.format(selected_title[:150])) # do not change the user status return Status.ADD_WAITING_TITLE if len(packs_by_title) > 1: logger.info('user has multiple packs with this title: %s', selected_title) markup = Keyboard.from_list(pack_names, add_back_button=True) # list with the links to the involved packs pack_links = ['<a href="{}">{}</a>'.format(utils.name2link(pack_name, bot_username=context.bot.username), pack_name) for pack_name in pack_names] text = Strings.ADD_STICKER_SELECTED_TITLE_MULTIPLE.format(selected_title, '\n• '.join(pack_links)) update.message.reply_html(text, reply_markup=markup) return Status.ADD_WAITING_NAME # we now have to wait for the user to tap on a pack name logger.info('there is only one pack with the selected title (animated: %s), proceeding...', pack_animated) pack_name = '{}_by_{}'.format(pack_names[0], context.bot.username) context.user_data['pack'] = dict(name=pack_name, animated=pack_animated) pack_link = utils.name2link(pack_name) base_string = Strings.ADD_STICKER_PACK_SELECTED_STATIC if not pack_animated else Strings.ADD_STICKER_PACK_SELECTED_ANIMATED update.message.reply_html(base_string.format(pack_link), reply_markup=Keyboard.HIDE) if pack_animated: return Status.WAITING_ANIMATED_STICKERS else: return Status.WAITING_STATIC_STICKERS
def on_count_command(update: Update, context: CallbackContext): logger.info('/count') with session_scope() as session: packs = session.query(Pack).filter_by( user_id=update.effective_user.id).order_by(Pack.title).all() packs = [(p.title, p.name, p.is_animated) for p in packs] if not packs: update.message.reply_text(Strings.LIST_NO_PACKS) return update.message.reply_html("Hold on, this might take some time...") results_list = [] for pack in packs: logger.debug('checking pack: %s', pack[1]) pack_result_dict = dict(title=pack[0], name=pack[1], result=None) try: sticker_set = context.bot.get_sticker_set( user_id=update.effective_user.id, name=pack[1]) pack_result_dict['result'] = len(sticker_set.stickers) except TelegramError as telegram_error: logger.debug('api exception: %s', telegram_error.message) pack_result_dict['result'] = telegram_error.message results_list.append(pack_result_dict) strings_list = [ '<a href="{}">{}</a>: {}'.format(utils.name2link(p['name']), p['title'], p['result']) for p in results_list ] update.message.reply_html('• {}'.format('\n• '.join(strings_list)))
def on_first_sticker_receive(update: Update, context: CallbackContext): logger.info('first sticker of the pack received') logger.debug('user_data: %s', context.user_data) animated_pack = context.user_data['pack']['animated'] if update.message.document: animated_sticker = False else: animated_sticker = update.message.sticker.is_animated if animated_pack and not animated_sticker: logger.info('invalid sticker: static sticker for an animated pack') update.message.reply_text(Strings.ADD_STICKER_EXPECTING_ANIMATED) return Status.CREATE_WAITING_FIRST_STICKER elif not animated_pack and animated_sticker: logger.info('invalid sticker: animated sticker for a static pack') update.message.reply_text(Strings.ADD_STICKER_EXPECTING_STATIC) return Status.CREATE_WAITING_FIRST_STICKER else: logger.info( 'sticker type ok (animated pack: %s, animated sticker: %s)', animated_pack, animated_sticker) title, name = context.user_data['pack'].get( 'title', None), context.user_data['pack'].get('name', None) if not title or not name: logger.error('pack title or name missing (title: %s, name: %s)', title, name) update.message.reply_text( Strings.PACK_CREATION_FIRST_STICKER_PACK_DATA_MISSING) context.user_data.pop('pack', None) # remove temp info return ConversationHandler.END full_name = '{}_by_{}'.format(name, context.bot.username) user_emojis = context.user_data['pack'].pop('emojis', None) # we also remove them sticker = StickerFile(bot=context.bot, message=update.message, emojis=user_emojis) sticker.download() try: logger.debug('executing API request...') request_payload = dict( user_id=update.effective_user.id, title=title, name=full_name, emojis=''.join(sticker.emojis), ) sticker.create_set(**request_payload) except (error.PackInvalid, error.NameInvalid, error.NameAlreadyOccupied) as e: logger.error('Telegram error while creating stickers pack: %s', e.message) if isinstance(e, error.NameAlreadyOccupied): # there's already a pack with that link update.message.reply_html( Strings.PACK_CREATION_ERROR_DUPLICATE_NAME.format( utils.name2link(full_name))) elif isinstance(e, (error.PackInvalid, error.NameInvalid)): update.message.reply_text(Strings.PACK_CREATION_ERROR_INVALID_NAME) context.user_data['pack'].pop('name', None) # remove pack name sticker.close() return Status.CREATE_WAITING_NAME # do not continue, wait for another name except error.InvalidAnimatedSticker as e: logger.error('Telegram error while creating animated pack: %s', e.message) update.message.reply_html(Strings.ADD_STICKER_INVALID_ANIMATED, quote=True, disable_web_page_preview=True) return Status.CREATE_WAITING_FIRST_STICKER except error.FloodControlExceeded as e: logger.error('Telegram error while creating animated pack: %s', e.message) retry_in = re.search(r'retry in (\d+) seconds', e.message, re.I).group(1) # Retry in 8 seconds text = Strings.ADD_STICKER_FLOOD_EXCEPTION.format(retry_in) update.message.reply_html(text, quote=True, disable_web_page_preview=True) return ConversationHandler.END # do not continue, end the conversation except error.UnknwonError as e: logger.error('Unknown error while creating the pack: %s', e.message) update.message.reply_html( Strings.PACK_CREATION_ERROR_GENERIC.format(e.message)) context.user_data.pop('pack', None) # remove temp data sticker.close() return ConversationHandler.END # do not continue, end the conversation else: # success pack_row = Pack(user_id=update.effective_user.id, name=full_name, title=title, is_animated=animated_pack) with session_scope() as session: session.add(pack_row) # db.save_pack(update.effective_user.id, full_name, title) pack_link = utils.name2link(full_name) update.message.reply_html( Strings.PACK_CREATION_PACK_CREATED.format(pack_link)) sticker.close() # remove sticker files context.user_data['pack']['name'] = full_name # do not remove temporary data (user_data['pack']) because we are still adding stickers # wait for other stickers if animated_pack: return Status.WAITING_ANIMATED_STICKERS else: return Status.WAITING_STATIC_STICKERS
def add_sticker_to_set(update: Update, context: CallbackContext, animated_pack): name = context.user_data['pack'].get('name', None) if not name: logger.error('pack name missing (%s)', name) update.message.reply_text(Strings.ADD_STICKER_PACK_DATA_MISSING) context.user_data.pop('pack', None) # remove temp info return ConversationHandler.END user_emojis = context.user_data['pack'].pop('emojis', None) # we also remove them sticker = StickerFile(bot=context.bot, message=update.message, emojis=user_emojis) sticker.download(prepare_png=True) pack_link = utils.name2link(name) # we edit this flag so the 'finally' statement can end the conversation if needed by an 'except' end_conversation = False try: logger.debug('executing request...') sticker.add_to_set(name) except error.PackFull: max_pack_size = MAX_PACK_SIZE_ANIMATED if animated_pack else MAX_PACK_SIZE_STATIC update.message.reply_html(Strings.ADD_STICKER_PACK_FULL.format( pack_link, max_pack_size), quote=True) end_conversation = True # end the conversation when a pack is full except error.FileDimensionInvalid: logger.error('resized sticker has the wrong size: %s', str(sticker)) update.message.reply_html( Strings.ADD_STICKER_SIZE_ERROR.format(*sticker.size), quote=True) except error.InvalidAnimatedSticker: update.message.reply_html(Strings.ADD_STICKER_INVALID_ANIMATED, quote=True, disable_web_page_preview=True) except error.PackInvalid: # pack name invalid or that pack has been deleted: delete it from the db with session_scope() as session: deleted_rows = session.query(Pack).filter( Pack.user_id == update.effective_user.id, Pack.name == name).delete('fetch') logger.debug('rows deleted: %d', deleted_rows or 0) # get the remaining packs' titles pack_titles = [ t.title for t in session.query(Pack.title).filter_by( user_id=update.effective_user.id).all() ] if not pack_titles: # user doesn't have any other pack to chose from, reset his status update.message.reply_html( Strings.ADD_STICKER_PACK_NOT_VALID_NO_PACKS.format(pack_link)) logger.debug('calling sticker.delete()...') sticker.close() return ConversationHandler.END else: # make the user select another pack from the keyboard markup = Keyboard.from_list(pack_titles) update.message.reply_html( Strings.ADD_STICKER_PACK_NOT_VALID.format(pack_link), reply_markup=markup) context.user_data['pack'].pop('name', None) # remove temporary data logger.debug('calling sticker.delete()...') sticker.close() return Status.ADD_WAITING_TITLE except error.UnknwonError as e: update.message.reply_html(Strings.ADD_STICKER_GENERIC_ERROR.format( pack_link, e.message), quote=True) except Exception as e: logger.error('non-telegram exception while adding a sticker to a set', exc_info=True) raise e # this is not raised else: if not user_emojis: text = Strings.ADD_STICKER_SUCCESS.format(pack_link) else: text = Strings.ADD_STICKER_SUCCESS_USER_EMOJIS.format( pack_link, ''.join(user_emojis)) update.message.reply_html(text, quote=True) finally: # this is entered even when we enter the 'else' or we return in an 'except' # https://stackoverflow.com/a/19805746 logger.debug('calling sticker.close()...') sticker.close() if end_conversation: return ConversationHandler.END if animated_pack: return Status.WAITING_ANIMATED_STICKERS else: return Status.WAITING_STATIC_STICKERS
def on_first_sticker_receive(update: Update, context: CallbackContext): logger.info('first sticker of the pack received') logger.debug('user_data: %s', context.user_data) title, name = context.user_data['pack'].get( 'title', None), context.user_data['pack'].get('name', None) if not title or not name: logger.error('pack title or name missing (title: %s, name: %s)', title, name) update.message.reply_text( Strings.PACK_CREATION_FIRST_STICKER_PACK_DATA_MISSING) context.user_data.pop('pack', None) # remove temp info return ConversationHandler.END full_name = '{}_by_{}'.format(name, context.bot.username) sticker = StickerFile(update.message.sticker or update.message.document, caption=update.message.caption) sticker.download(prepare_png=True) try: logger.debug('executing API request...') StickerFile.create_set( bot=context.bot, user_id=update.effective_user.id, title=title, name=full_name, emojis=sticker.emoji, # we need to use an input file becase a tempfile.SpooledTemporaryFile has a 'name' attribute which # makes python-telegram-bot retrieve the file's path using os (https://github.com/python-telegram-bot/python-telegram-bot/blob/2a3169a22f7227834dd05a35f90306375136e41a/telegram/files/inputfile.py#L58) # to populate the 'filename' attribute, which would result an exception since it is # a byte object. That means we have to do it ourself by creating the InputFile and # assigning it a custom 'filename' png_sticker=sticker.png_input_file) except (error.PackInvalid, error.NameInvalid, error.NameAlreadyOccupied) as e: logger.error('Telegram error while creating stickers pack: %s', e.message) if isinstance(e, error.NameAlreadyOccupied): # there's already a pack with that link update.message.reply_html( Strings.PACK_CREATION_ERROR_DUPLICATE_NAME.format( utils.name2link(full_name))) elif isinstance(e, (error.PackInvalid, error.NameInvalid)): update.message.reply_text(Strings.PACK_CREATION_ERROR_INVALID_NAME) context.user_data['pack'].pop('name', None) # remove pack name sticker.close() return WAITING_NAME # do not continue, wait for another name except error.UnknwonError as e: logger.error('Unknown error while creating the pack: %s', e.message) update.message.reply_html( Strings.PACK_CREATION_ERROR_GENERIC.format(e.message)) context.user_data.pop('pack', None) # remove temp data sticker.close() return ConversationHandler.END # do not continue, end the conversation else: # success pack_row = Pack(user_id=update.effective_user.id, name=full_name, title=title) with session_scope() as session: session.add(pack_row) # db.save_pack(update.effective_user.id, full_name, title) pack_link = utils.name2link(full_name) update.message.reply_html( Strings.PACK_CREATION_PACK_CREATED.format(pack_link)) sticker.close() # remove sticker files context.user_data['pack']['name'] = full_name # do not remove temporary data (user_data['pack']) because we are still adding stickers return ADDING_STICKERS # wait for other stickers
def process_pack(pack_name: str, update: Update, context: CallbackContext): pack_link = utils.name2link(pack_name, context.bot.username) warning_text = check_pack_name(update.effective_user.id, pack_name, context) if warning_text: update.message.reply_html(warning_text) return Status.WAITING_STICKER_OR_PACK_NAME refresh_dummy_file = False if context.user_data.get("dummy_png_file", None): now = datetime.datetime.utcnow() # refresh every two weeks refresh_on = context.user_data["dummy_png_file"]["generated_on"] + datetime.timedelta(14) if now > refresh_on: refresh_dummy_file = True else: dummy_png: File = context.user_data["dummy_png_file"]["file"] else: refresh_dummy_file = True if refresh_dummy_file: logger.debug("refreshing dummy png file") with open("assets/dummy_sticker.png", "rb") as f: dummy_png: File = context.bot.upload_sticker_file(update.effective_user.id, f) context.user_data["dummy_png_file"] = {"file": dummy_png, "generated_on": datetime.datetime.utcnow()} try: context.bot.add_sticker_to_set( user_id=update.effective_user.id, name=pack_name, png_sticker=dummy_png.file_id, emojis=DUMMY_EMOJI ) logger.debug("successfully added dummy sticker to pack <%s>", pack_name) except (TelegramError, BadRequest) as e: error_message = e.message.lower() if "stickerset_invalid" in error_message: update.message.reply_html(Strings.READD_PACK_INVALID.format(pack_link)) return Status.WAITING_STICKER_OR_PACK_NAME else: logger.error("/readd: api error while adding dummy sticker to pack <%s>: %s", pack_name, e.message) update.message.reply_html(Strings.READD_UNKNOWN_API_EXCEPTION.format(pack_link, e.message)) return Status.WAITING_STICKER_OR_PACK_NAME sticker_set: StickerSet = context.bot.get_sticker_set(pack_name) if sticker_set.stickers[-1].emoji == DUMMY_EMOJI: sticker_to_remove = sticker_set.stickers[-1] else: logger.warning("dummy emoji and the emoji of the last sticker in the set do not match") sticker_to_remove = None pack_row = Pack( user_id=update.effective_user.id, name=sticker_set.name, title=sticker_set.title, is_animated=sticker_set.is_animated ) with session_scope() as session: session.add(pack_row) stickerset_title_link = utils.stickerset_title_link(sticker_set) update.message.reply_html( Strings.READD_SAVED.format(stickerset_title_link) ) # We do this here to let the API figure out we just added the sticker with that file_id to the pack # it will raise an exception anyway though (Sticker_invalid) # we might just ignore it. The user now can manage the pack, and can remove the dummy sticker manually # Also, it might be the case (IT *IS* THE CASE) that the dummy sticker added to the pack gets its own file_id, so # the file_id returned by upload_sticker_file should be used to remove the sticker. # We then use the file_id of the last sticker in the pack, but I guess we can't be 100% sure # the get_sticker_set request returned the pack with also the dummy sticker we added one second before if not sticker_to_remove: # the dummy emoji and the emoji of the last sticker in the pack did not match update.message.reply_html(Strings.READD_DUMMY_STICKER_NOT_REMOVED) else: try: context.bot.delete_sticker_from_set(sticker=sticker_to_remove.file_id) logger.debug("successfully removed dummy sticker from pack <%s>", pack_name) except (TelegramError, BadRequest) as e: error_message = e.message.lower() if "sticker_invalid" in error_message: update.message.reply_html(Strings.READD_DUMMY_STICKER_NOT_REMOVED) else: logger.error("/readd: api error while removing dummy sticker from pack <%s>: %s", pack_name, e.message) update.message.reply_html(Strings.READD_DUMMY_STICKER_NOT_REMOVED_UNKNOWN.format(error_message)) return ConversationHandler.END