gallery.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. # █ █ ▀ █▄▀ ▄▀█ █▀█ ▀
  2. # █▀█ █ █ █ █▀█ █▀▄ █
  3. # © Copyright 2022
  4. # https://t.me/hikariatama
  5. #
  6. # 🔒 Licensed under the GNU AGPLv3
  7. # 🌐 https://www.gnu.org/licenses/agpl-3.0.html
  8. import asyncio
  9. import contextlib
  10. import copy
  11. import functools
  12. import logging
  13. import time
  14. import traceback
  15. import typing
  16. from aiogram.types import (
  17. CallbackQuery,
  18. InlineKeyboardMarkup,
  19. InlineQuery,
  20. InlineQueryResultGif,
  21. InlineQueryResultPhoto,
  22. InputMediaAnimation,
  23. InputMediaPhoto,
  24. )
  25. from aiogram.utils.exceptions import BadRequest, RetryAfter
  26. from telethon.tl.types import Message
  27. from telethon.errors.rpcerrorlist import ChatSendInlineForbiddenError
  28. from telethon.extensions.html import CUSTOM_EMOJIS
  29. from urllib.parse import urlparse
  30. import os
  31. from .. import utils, main
  32. from ..types import HikkaReplyMarkup
  33. from .types import InlineUnit, InlineMessage
  34. logger = logging.getLogger(__name__)
  35. class ListGalleryHelper:
  36. def __init__(self, lst: typing.List[str]):
  37. self.lst = lst
  38. self._current_index = -1
  39. def __call__(self) -> str:
  40. self._current_index += 1
  41. return self.lst[self._current_index % len(self.lst)]
  42. def by_index(self, index: int) -> str:
  43. return self.lst[index % len(self.lst)]
  44. class Gallery(InlineUnit):
  45. async def gallery(
  46. self,
  47. message: typing.Union[Message, int],
  48. next_handler: typing.Union[callable, typing.List[str]],
  49. caption: typing.Union[typing.List[str], str, callable] = "",
  50. *,
  51. custom_buttons: typing.Optional[HikkaReplyMarkup] = None,
  52. force_me: bool = False,
  53. always_allow: typing.Optional[typing.List[int]] = None,
  54. manual_security: bool = False,
  55. disable_security: bool = False,
  56. ttl: typing.Union[int, bool] = False,
  57. on_unload: typing.Optional[callable] = None,
  58. preload: typing.Union[bool, int] = False,
  59. gif: bool = False,
  60. silent: bool = False,
  61. _reattempt: bool = False,
  62. ) -> typing.Union[bool, InlineMessage]:
  63. """
  64. Send inline gallery to chat
  65. :param caption: Caption for photo, or callable, returning caption
  66. :param message: Where to send inline. Can be either `Message` or `int`
  67. :param next_handler: Callback function, which must return url for next photo or list with photo urls
  68. :param custom_buttons: Custom buttons to add above native ones
  69. :param force_me: Either this gallery buttons must be pressed only by owner scope or no
  70. :param always_allow: Users, that are allowed to press buttons in addition to previous rules
  71. :param ttl: Time, when the gallery is going to be unloaded. Unload means, that the gallery
  72. will become unusable. Pay attention, that ttl can't
  73. be bigger, than default one (1 day) and must be either `int` or `False`
  74. :param on_unload: Callback, called when gallery is unloaded and/or closed. You can clean up trash
  75. or perform another needed action
  76. :param preload: Either to preload gallery photos beforehand or no. If yes - specify threshold to
  77. be loaded. Toggle this attribute, if your callback is too slow to load photos
  78. in real time
  79. :param gif: Whether the gallery will be filled with gifs. If you omit this argument and specify
  80. gifs in `next_handler`, Hikka will try to determine the filetype of these images
  81. :param manual_security: By default, Hikka will try to inherit inline buttons security from the caller (command)
  82. If you want to avoid this, pass `manual_security=True`
  83. :param disable_security: By default, Hikka will try to inherit inline buttons security from the caller (command)
  84. If you want to disable all security checks on this gallery in particular, pass `disable_security=True`
  85. :param silent: Whether the gallery must be sent silently (w/o "Opening gallery..." message)
  86. :return: If gallery is sent, returns :obj:`InlineMessage`, otherwise returns `False`
  87. """
  88. with contextlib.suppress(AttributeError):
  89. _hikka_client_id_logging_tag = copy.copy(self._client.tg_id)
  90. custom_buttons = self._validate_markup(custom_buttons)
  91. if not (
  92. isinstance(caption, str)
  93. or isinstance(caption, list)
  94. and all(isinstance(item, str) for item in caption)
  95. ) and not callable(caption):
  96. logger.error(
  97. "Invalid type for `caption`. Expected `str` or `list` or `callable`,"
  98. " got `%s`",
  99. type(caption),
  100. )
  101. return False
  102. if isinstance(caption, list):
  103. caption = ListGalleryHelper(caption)
  104. if not isinstance(manual_security, bool):
  105. logger.error(
  106. "Invalid type for `manual_security`. Expected `bool`, got `%s`",
  107. type(manual_security),
  108. )
  109. return False
  110. if not isinstance(silent, bool):
  111. logger.error(
  112. "Invalid type for `silent`. Expected `bool`, got `%s`", type(silent)
  113. )
  114. return False
  115. if not isinstance(disable_security, bool):
  116. logger.error(
  117. "Invalid type for `disable_security`. Expected `bool`, got `%s`",
  118. type(disable_security),
  119. )
  120. return False
  121. if not isinstance(message, (Message, int)):
  122. logger.error(
  123. "Invalid type for `message`. Expected `Message` or `int`, got `%s`",
  124. type(message),
  125. )
  126. return False
  127. if not isinstance(force_me, bool):
  128. logger.error(
  129. "Invalid type for `force_me`. Expected `bool`, got `%s`", type(force_me)
  130. )
  131. return False
  132. if not isinstance(gif, bool):
  133. logger.error("Invalid type for `gif`. Expected `bool`, got `%s`", type(gif))
  134. return False
  135. if (
  136. not isinstance(preload, (bool, int))
  137. or isinstance(preload, bool)
  138. and preload
  139. ):
  140. logger.error(
  141. "Invalid type for `preload`. Expected `int` or `False`, got `%s`",
  142. type(preload),
  143. )
  144. return False
  145. if always_allow and not isinstance(always_allow, list):
  146. logger.error(
  147. "Invalid type for `always_allow`. Expected `list`, got `%s`",
  148. type(always_allow),
  149. )
  150. return False
  151. if not always_allow:
  152. always_allow = []
  153. if not isinstance(ttl, int) and ttl:
  154. logger.error(
  155. "Invalid type for `ttl`. Expected `int` or `False`, got `%s`", type(ttl)
  156. )
  157. return False
  158. if isinstance(next_handler, list):
  159. if all(isinstance(i, str) for i in next_handler):
  160. next_handler = ListGalleryHelper(next_handler)
  161. else:
  162. logger.error(
  163. "Invalid type for `next_handler`. Expected `callable` or `list` of"
  164. " `str`, got `%s`",
  165. type(next_handler),
  166. )
  167. return False
  168. unit_id = utils.rand(16)
  169. btn_call_data = utils.rand(10)
  170. try:
  171. if isinstance(next_handler, ListGalleryHelper):
  172. photo_url = next_handler.lst
  173. else:
  174. photo_url = await self._call_photo(next_handler)
  175. if not photo_url:
  176. return False
  177. except Exception:
  178. logger.exception("Error while parsing first photo in gallery")
  179. return False
  180. perms_map = None if manual_security else self._find_caller_sec_map()
  181. self._units[unit_id] = {
  182. "type": "gallery",
  183. "caption": caption,
  184. "chat": None,
  185. "message_id": None,
  186. "uid": unit_id,
  187. "photo_url": (photo_url if isinstance(photo_url, str) else photo_url[0]),
  188. "next_handler": next_handler,
  189. "btn_call_data": btn_call_data,
  190. "photos": [photo_url] if isinstance(photo_url, str) else photo_url,
  191. "current_index": 0,
  192. "future": asyncio.Event(),
  193. **({"ttl": round(time.time()) + ttl} if ttl else {}),
  194. **({"force_me": force_me} if force_me else {}),
  195. **({"disable_security": disable_security} if disable_security else {}),
  196. **({"on_unload": on_unload} if callable(on_unload) else {}),
  197. **({"preload": preload} if preload else {}),
  198. **({"gif": gif} if gif else {}),
  199. **({"always_allow": always_allow} if always_allow else {}),
  200. **({"perms_map": perms_map} if perms_map else {}),
  201. **({"message": message} if isinstance(message, Message) else {}),
  202. **({"custom_buttons": custom_buttons} if custom_buttons else {}),
  203. }
  204. self._custom_map[btn_call_data] = {
  205. "handler": asyncio.coroutine(
  206. functools.partial(
  207. self._gallery_page,
  208. unit_id=unit_id,
  209. )
  210. ),
  211. **(
  212. {"ttl": self._units[unit_id]["ttl"]}
  213. if "ttl" in self._units[unit_id]
  214. else {}
  215. ),
  216. **({"always_allow": always_allow} if always_allow else {}),
  217. **({"force_me": force_me} if force_me else {}),
  218. **({"disable_security": disable_security} if disable_security else {}),
  219. **({"perms_map": perms_map} if perms_map else {}),
  220. **({"message": message} if isinstance(message, Message) else {}),
  221. }
  222. if isinstance(message, Message) and not silent:
  223. try:
  224. status_message = await (
  225. message.edit if message.out else message.respond
  226. )(
  227. (
  228. utils.get_platform_emoji(self._client)
  229. if self._client.hikka_me.premium and CUSTOM_EMOJIS
  230. else "🌘"
  231. )
  232. + self._client.loader._lookup("translations").strings(
  233. "opening_gallery"
  234. )
  235. )
  236. except Exception:
  237. status_message = None
  238. else:
  239. status_message = None
  240. async def answer(msg: str):
  241. nonlocal message
  242. if isinstance(message, Message):
  243. await (message.edit if message.out else message.respond)(msg)
  244. else:
  245. await self._client.send_message(message, msg)
  246. try:
  247. q = await self._client.inline_query(self.bot_username, unit_id)
  248. m = await q[0].click(
  249. utils.get_chat_id(message) if isinstance(message, Message) else message,
  250. reply_to=message.reply_to_msg_id
  251. if isinstance(message, Message)
  252. else None,
  253. )
  254. except ChatSendInlineForbiddenError:
  255. await answer(
  256. self._client.loader._lookup("translations").strings("inline403")
  257. )
  258. except Exception:
  259. logger.exception("Error sending inline gallery")
  260. del self._units[unit_id]
  261. if _reattempt:
  262. logger.exception("Can't send gallery")
  263. if not self._db.get(main.__name__, "inlinelogs", True):
  264. msg = self._client.loader._lookup("translations").strings(
  265. "invoke_failed"
  266. )
  267. else:
  268. exc = traceback.format_exc()
  269. # Remove `Traceback (most recent call last):`
  270. exc = "\n".join(exc.splitlines()[1:])
  271. msg = (
  272. "<b>🚫 Gallery invoke failed!</b>\n\n"
  273. f"<b>🧾 Logs:</b>\n<code>{utils.escape_html(exc)}</code>"
  274. )
  275. del self._units[unit_id]
  276. await answer(msg)
  277. return False
  278. kwargs = utils.get_kwargs()
  279. kwargs["_reattempt"] = True
  280. return await self.gallery(**kwargs)
  281. await self._units[unit_id]["future"].wait()
  282. del self._units[unit_id]["future"]
  283. self._units[unit_id]["chat"] = utils.get_chat_id(m)
  284. self._units[unit_id]["message_id"] = m.id
  285. if isinstance(message, Message) and message.out:
  286. await message.delete()
  287. if status_message and not message.out:
  288. await status_message.delete()
  289. if not isinstance(next_handler, ListGalleryHelper):
  290. asyncio.ensure_future(self._load_gallery_photos(unit_id))
  291. return InlineMessage(self, unit_id, self._units[unit_id]["inline_message_id"])
  292. async def _call_photo(self, callback: callable) -> typing.Union[str, bool]:
  293. """Parses photo url from `callback`. Returns url on success, otherwise `False`
  294. """
  295. if isinstance(callback, str):
  296. photo_url = callback
  297. elif isinstance(callback, list):
  298. photo_url = callback[0]
  299. elif asyncio.iscoroutinefunction(callback):
  300. photo_url = await callback()
  301. elif callable(callback):
  302. photo_url = callback()
  303. else:
  304. logger.error(
  305. "Invalid type for `next_handler`. Expected `str`, `list`, `callable` or"
  306. " `asyncio.coroutine`, got %s",
  307. type(callback),
  308. )
  309. return False
  310. if not isinstance(photo_url, (str, list)):
  311. logger.error(
  312. "Got invalid result from `next_handler`. Expected `str` or `list`,"
  313. " got %s",
  314. type(photo_url),
  315. )
  316. return False
  317. return photo_url
  318. async def _load_gallery_photos(self, unit_id: str):
  319. """Preloads photo. Should be called via ensure_future"""
  320. unit = self._units[unit_id]
  321. photo_url = await self._call_photo(unit["next_handler"])
  322. self._units[unit_id]["photos"] += (
  323. [photo_url] if isinstance(photo_url, str) else photo_url
  324. )
  325. unit = self._units[unit_id]
  326. # If only one preload was insufficient to load needed amount of photos
  327. if unit.get("preload", False) and len(unit["photos"]) - unit[
  328. "current_index"
  329. ] < unit.get("preload", False):
  330. # Start load again
  331. asyncio.ensure_future(self._load_gallery_photos(unit_id))
  332. async def _gallery_slideshow_loop(
  333. self,
  334. call: CallbackQuery,
  335. unit_id: str = None,
  336. ):
  337. while True:
  338. await asyncio.sleep(7)
  339. unit = self._units[unit_id]
  340. if unit_id not in self._units or not unit.get("slideshow", False):
  341. return
  342. if unit["current_index"] + 1 >= len(unit["photos"]) and isinstance(
  343. unit["next_handler"],
  344. ListGalleryHelper,
  345. ):
  346. del self._units[unit_id]["slideshow"]
  347. self._units[unit_id]["current_index"] -= 1
  348. await self._gallery_page(
  349. call,
  350. self._units[unit_id]["current_index"] + 1,
  351. unit_id=unit_id,
  352. )
  353. async def _gallery_slideshow(
  354. self,
  355. call: CallbackQuery,
  356. unit_id: str = None,
  357. ):
  358. if not self._units[unit_id].get("slideshow", False):
  359. self._units[unit_id]["slideshow"] = True
  360. await self.bot.edit_message_reply_markup(
  361. inline_message_id=call.inline_message_id,
  362. reply_markup=self._gallery_markup(unit_id),
  363. )
  364. await call.answer("✅ Slideshow on")
  365. else:
  366. del self._units[unit_id]["slideshow"]
  367. await self.bot.edit_message_reply_markup(
  368. inline_message_id=call.inline_message_id,
  369. reply_markup=self._gallery_markup(unit_id),
  370. )
  371. await call.answer("🚫 Slideshow off")
  372. return
  373. asyncio.ensure_future(
  374. self._gallery_slideshow_loop(
  375. call,
  376. unit_id,
  377. )
  378. )
  379. async def _gallery_back(
  380. self,
  381. call: CallbackQuery,
  382. unit_id: str = None,
  383. ):
  384. queue = self._units[unit_id]["photos"]
  385. if not queue:
  386. await call.answer("No way back", show_alert=True)
  387. return
  388. self._units[unit_id]["current_index"] -= 1
  389. if self._units[unit_id]["current_index"] < 0:
  390. self._units[unit_id]["current_index"] = 0
  391. await call.answer("No way back")
  392. return
  393. try:
  394. await self.bot.edit_message_media(
  395. inline_message_id=call.inline_message_id,
  396. media=self._get_current_media(unit_id),
  397. reply_markup=self._gallery_markup(unit_id),
  398. )
  399. except RetryAfter as e:
  400. await call.answer(
  401. f"Got FloodWait. Wait for {e.timeout} seconds",
  402. show_alert=True,
  403. )
  404. except Exception:
  405. logger.exception("Exception while trying to edit media")
  406. await call.answer("Error occurred", show_alert=True)
  407. return
  408. def _get_current_media(
  409. self,
  410. unit_id: str,
  411. ) -> typing.Union[InputMediaPhoto, InputMediaAnimation]:
  412. """Return current media, which should be updated in gallery"""
  413. media = self._get_next_photo(unit_id)
  414. try:
  415. path = urlparse(media).path
  416. ext = os.path.splitext(path)[1]
  417. except Exception:
  418. ext = None
  419. if self._units[unit_id].get("gif", False) or ext in {".gif", ".mp4"}:
  420. return InputMediaAnimation(
  421. media=media,
  422. caption=self._get_caption(
  423. unit_id,
  424. index=self._units[unit_id]["current_index"],
  425. ),
  426. parse_mode="HTML",
  427. )
  428. return InputMediaPhoto(
  429. media=media,
  430. caption=self._get_caption(
  431. unit_id,
  432. index=self._units[unit_id]["current_index"],
  433. ),
  434. parse_mode="HTML",
  435. )
  436. async def _gallery_page(
  437. self,
  438. call: CallbackQuery,
  439. page: typing.Union[int, str],
  440. unit_id: str = None,
  441. ):
  442. if page == "slideshow":
  443. await self._gallery_slideshow(call, unit_id)
  444. return
  445. if page == "close":
  446. await self._delete_unit_message(call, unit_id=unit_id)
  447. return
  448. if page < 0:
  449. await call.answer("No way back")
  450. return
  451. if page > len(self._units[unit_id]["photos"]) - 1 and isinstance(
  452. self._units[unit_id]["next_handler"], ListGalleryHelper
  453. ):
  454. await call.answer("No way forward")
  455. return
  456. self._units[unit_id]["current_index"] = page
  457. if not isinstance(self._units[unit_id]["next_handler"], ListGalleryHelper):
  458. # If we exceeded photos limit in gallery and need to preload more
  459. if self._units[unit_id]["current_index"] >= len(
  460. self._units[unit_id]["photos"]
  461. ):
  462. await self._load_gallery_photos(unit_id)
  463. # If we still didn't get needed photo index
  464. if self._units[unit_id]["current_index"] >= len(
  465. self._units[unit_id]["photos"]
  466. ):
  467. await call.answer("Can't load next photo")
  468. return
  469. if (
  470. len(self._units[unit_id]["photos"])
  471. - self._units[unit_id]["current_index"]
  472. < self._units[unit_id].get("preload", 0) // 2
  473. ):
  474. logger.debug("Started preload for gallery %s", unit_id)
  475. asyncio.ensure_future(self._load_gallery_photos(unit_id))
  476. try:
  477. await self.bot.edit_message_media(
  478. inline_message_id=call.inline_message_id,
  479. media=self._get_current_media(unit_id),
  480. reply_markup=self._gallery_markup(unit_id),
  481. )
  482. except BadRequest:
  483. logger.debug("Error fetching photo content, attempting load next one")
  484. del self._units[unit_id]["photos"][self._units[unit_id]["current_index"]]
  485. self._units[unit_id]["current_index"] -= 1
  486. return await self._gallery_page(call, page, unit_id)
  487. except RetryAfter as e:
  488. await call.answer(
  489. f"Got FloodWait. Wait for {e.timeout} seconds",
  490. show_alert=True,
  491. )
  492. return
  493. except Exception:
  494. logger.exception("Exception while trying to edit media")
  495. await call.answer("Error occurred", show_alert=True)
  496. return
  497. def _get_next_photo(self, unit_id: str) -> str:
  498. """Returns next photo"""
  499. try:
  500. return self._units[unit_id]["photos"][self._units[unit_id]["current_index"]]
  501. except IndexError:
  502. logger.error(
  503. "Got IndexError in `_get_next_photo`. %s / %s",
  504. self._units[unit_id]["current_index"],
  505. len(self._units[unit_id]["photos"]),
  506. )
  507. return self._units[unit_id]["photos"][0]
  508. def _get_caption(self, unit_id: str, index: int = 0) -> str:
  509. """Calls and returnes caption for gallery"""
  510. caption = self._units[unit_id].get("caption", "")
  511. if isinstance(caption, ListGalleryHelper):
  512. return caption.by_index(index)
  513. return (
  514. caption
  515. if isinstance(caption, str)
  516. else caption()
  517. if callable(caption)
  518. else ""
  519. )
  520. def _gallery_markup(self, unit_id: str) -> InlineKeyboardMarkup:
  521. """Generates aiogram markup for `gallery`"""
  522. callback = functools.partial(self._gallery_page, unit_id=unit_id)
  523. unit = self._units[unit_id]
  524. return self.generate_markup(
  525. (
  526. (
  527. unit.get("custom_buttons", [])
  528. + self.build_pagination(
  529. unit_id=unit_id,
  530. callback=callback,
  531. total_pages=len(unit["photos"]),
  532. )
  533. + [
  534. [
  535. *(
  536. [
  537. {
  538. "text": "⏪",
  539. "callback": callback,
  540. "args": (unit["current_index"] - 1,),
  541. }
  542. ]
  543. if unit["current_index"] > 0
  544. else []
  545. ),
  546. *(
  547. [
  548. {
  549. "text": "🛑"
  550. if unit.get("slideshow", False)
  551. else "⏱",
  552. "callback": callback,
  553. "args": ("slideshow",),
  554. }
  555. ]
  556. if unit["current_index"] < len(unit["photos"]) - 1
  557. or not isinstance(
  558. unit["next_handler"], ListGalleryHelper
  559. )
  560. else []
  561. ),
  562. *(
  563. [
  564. {
  565. "text": "⏩",
  566. "callback": callback,
  567. "args": (unit["current_index"] + 1,),
  568. }
  569. ]
  570. if unit["current_index"] < len(unit["photos"]) - 1
  571. or not isinstance(
  572. unit["next_handler"], ListGalleryHelper
  573. )
  574. else []
  575. ),
  576. ]
  577. ]
  578. )
  579. + [[{"text": "🔻 Close", "callback": callback, "args": ("close",)}]]
  580. )
  581. )
  582. async def _gallery_inline_handler(self, inline_query: InlineQuery):
  583. for unit in self._units.copy().values():
  584. if (
  585. inline_query.from_user.id == self._me
  586. and inline_query.query == unit["uid"]
  587. and unit["type"] == "gallery"
  588. ):
  589. try:
  590. path = urlparse(unit["photo_url"]).path
  591. ext = os.path.splitext(path)[1]
  592. except Exception:
  593. ext = None
  594. args = {
  595. "thumb_url": "https://img.icons8.com/fluency/344/loading.png",
  596. "caption": self._get_caption(unit["uid"], index=0),
  597. "parse_mode": "HTML",
  598. "reply_markup": self._gallery_markup(unit["uid"]),
  599. "id": utils.rand(20),
  600. "title": "Processing inline gallery",
  601. }
  602. if unit.get("gif", False) or ext in {".gif", ".mp4"}:
  603. await inline_query.answer(
  604. [InlineQueryResultGif(gif_url=unit["photo_url"], **args)]
  605. )
  606. return
  607. await inline_query.answer(
  608. [InlineQueryResultPhoto(photo_url=unit["photo_url"], **args)],
  609. cache_time=0,
  610. )