Queer European MD passionate about IT

ciclopi.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516
  1. """Get information about bike sharing in Pisa.
  2. Available bikes in bike sharing stations.
  3. """
  4. # Standard library modules
  5. import asyncio
  6. import datetime
  7. import math
  8. # Third party modules
  9. from davtelepot.utilities import (
  10. async_wrapper, CachedPage, extract, get_cleaned_text,
  11. line_drawing_unordered_list, make_button, make_inline_keyboard,
  12. make_lines_of_buttons
  13. )
  14. default_ciclopi_messages = {
  15. 'ciclopi_command': {
  16. 'description': {
  17. 'en': "CiloPi stations status",
  18. 'it': "Stato delle stazioni CicloPi"
  19. },
  20. 'reply_keyboard_button': {
  21. 'en': "CicloPi 🚲",
  22. 'it': "CicloPi 🚲"
  23. }
  24. }
  25. }
  26. _URL = "http://www.ciclopi.eu/frmLeStazioni.aspx"
  27. ciclopi_webpage = CachedPage.get(
  28. _URL,
  29. datetime.timedelta(seconds=15),
  30. mode='html'
  31. )
  32. UNIT_TO_KM = {
  33. 'km': 1,
  34. 'm': 1000,
  35. 'mi': 0.621371192,
  36. 'nmi': 0.539956803,
  37. 'ft': 3280.839895013,
  38. 'in': 39370.078740158
  39. }
  40. CICLOPI_SETTINGS = {
  41. 'sort': dict(
  42. name="Ordina",
  43. description="scegli in che ordine visualizzare le stazioni CicloPi.",
  44. symbol="⏬"
  45. ),
  46. 'limit': dict(
  47. name="Numero di stazioni",
  48. description="scegli quante stazioni visualizzare.",
  49. symbol="#️⃣"
  50. ),
  51. 'fav': dict(
  52. name="Stazioni preferite",
  53. description="cambia le tue stazioni preferite.",
  54. symbol="⭐️"
  55. ),
  56. 'setpos': dict(
  57. name="Cambia posizione",
  58. description=(
  59. "imposta una posizione da cui ordinare le stazioni per distanza."
  60. ),
  61. symbol='🧭'
  62. )
  63. }
  64. CICLOPI_SORTING_CHOICES = {
  65. 0: dict(
  66. name='Borgo',
  67. description='in ordine di distanza crescente da Borgo Stretto.',
  68. short_description='per distanza da Borgo Stretto',
  69. symbol='🏛'
  70. ),
  71. 1: dict(
  72. name='Alfabetico',
  73. description='in ordine alfabetico.',
  74. short_description='per nome',
  75. symbol='🔤'
  76. ),
  77. 2: dict(
  78. name='Posizione',
  79. description='in ordine di distanza crescente dall\'ultima posizione '
  80. 'inviata. Di default sarà Borgo Stretto.',
  81. short_description='per distanza',
  82. symbol='🧭'
  83. ),
  84. 3: dict(
  85. name='Preferite',
  86. description='nell\'ordine che hai scelto.',
  87. short_description='in ordine personalizzato',
  88. symbol='⭐️'
  89. )
  90. }
  91. CICLOPI_STATIONS_TO_SHOW = {
  92. -1: dict(
  93. name="Solo le preferite",
  94. symbol='⭐️'
  95. ),
  96. 0: dict(
  97. name='Tutte',
  98. symbol='💯'
  99. ),
  100. 3: dict(
  101. name='3',
  102. symbol='3️⃣'
  103. ),
  104. 5: dict(
  105. name='5',
  106. symbol='5️⃣'
  107. ),
  108. 10: dict(
  109. name='10',
  110. symbol='🔟'
  111. )
  112. }
  113. def haversine_distance(lat1, lon1, lat2, lon2, degrees='dec', unit='m'):
  114. """
  115. Calculate the great circle distance between two points on Earth.
  116. (specified in decimal degrees)
  117. """
  118. assert unit in UNIT_TO_KM, "Invalid distance unit of measurement!"
  119. assert degrees in ['dec', 'rad'], "Invalid angle unit of measurement!"
  120. # Convert decimal degrees to radians
  121. if degrees == 'dec':
  122. lon1, lat1, lon2, lat2 = map(
  123. math.radians,
  124. [lon1, lat1, lon2, lat2]
  125. )
  126. average_earth_radius = 6371.0088 * UNIT_TO_KM[unit]
  127. return (
  128. 2
  129. * average_earth_radius
  130. * math.asin(
  131. math.sqrt(
  132. math.sin((lat2 - lat1) * 0.5) ** 2
  133. + math.cos(lat1)
  134. * math.cos(lat2)
  135. * math.sin((lon2 - lon1) * 0.5) ** 2
  136. )
  137. )
  138. )
  139. class Location():
  140. """Location in world map."""
  141. def __init__(self, coordinates):
  142. """Check and set instance attributes."""
  143. assert type(coordinates) is tuple, "`coordinates` must be a tuple"
  144. assert (
  145. len(coordinates) == 2
  146. and all(type(c) is float for c in coordinates)
  147. ), "`coordinates` must be two floats"
  148. self._coordinates = coordinates
  149. @property
  150. def coordinates(self):
  151. """Return a tuple (latitude, longitude)."""
  152. return self._coordinates
  153. @property
  154. def latitude(self):
  155. """Return latitude."""
  156. return self._coordinates[0]
  157. @property
  158. def longitude(self):
  159. """Return longitude."""
  160. return self._coordinates[1]
  161. def get_distance(self, other, *args, **kwargs):
  162. """Return the distance between two `Location`s."""
  163. return haversine_distance(
  164. self.latitude, self.longitude,
  165. other.latitude, other.longitude,
  166. *args, **kwargs
  167. )
  168. default_location = Location(
  169. (43.718518, 10.402165) # Borgo Stretto Station
  170. )
  171. class Station(Location):
  172. """CicloPi bike sharing station."""
  173. stations = {
  174. 1: dict(
  175. name='Aeroporto',
  176. coordinates=(43.699455, 10.400075),
  177. ),
  178. 2: dict(
  179. name='Stazione F.S.',
  180. coordinates=(43.708627, 10.399051),
  181. ),
  182. 3: dict(
  183. name='Comune Palazzo Blu',
  184. coordinates=(43.715541, 10.400505),
  185. ),
  186. 4: dict(
  187. name='Teatro Tribunale',
  188. coordinates=(43.716391, 10.405136),
  189. ),
  190. 5: dict(
  191. name='Borgo Stretto',
  192. coordinates=(43.718518, 10.402165),
  193. ),
  194. 6: dict(
  195. name='Polo Marzotto',
  196. coordinates=(43.719772, 10.407291),
  197. ),
  198. 7: dict(
  199. name='Duomo',
  200. coordinates=(43.722855, 10.391977),
  201. ),
  202. 8: dict(
  203. name='Pietrasantina',
  204. coordinates=(43.729020, 10.392726),
  205. ),
  206. 9: dict(
  207. name='Paparelli',
  208. coordinates=(43.724449, 10.410438),
  209. ),
  210. 10: dict(
  211. name='Pratale',
  212. coordinates=(43.7212554, 10.4180257),
  213. ),
  214. 11: dict(
  215. name='Ospedale Cisanello',
  216. coordinates=(43.705752, 10.441740),
  217. ),
  218. 12: dict(
  219. name='Sms Biblioteca',
  220. coordinates=(43.706565, 10.419136),
  221. ),
  222. 13: dict(
  223. name='Vittorio Emanuele',
  224. coordinates=(43.710182, 10.398751),
  225. ),
  226. 14: dict(
  227. name='Palacongressi',
  228. coordinates=(43.710014, 10.410232),
  229. ),
  230. 15: dict(
  231. name='Porta a Lucca',
  232. coordinates=(43.724247, 10.402269),
  233. ),
  234. 16: dict(
  235. name='Solferino',
  236. coordinates=(43.715698, 10.394999),
  237. ),
  238. 17: dict(
  239. name='San Rossore F.S.',
  240. coordinates=(43.718992, 10.384391),
  241. ),
  242. 18: dict(
  243. name='Guerrazzi',
  244. coordinates=(43.710358, 10.405337),
  245. ),
  246. 19: dict(
  247. name='Livornese',
  248. coordinates=(43.708114, 10.384021),
  249. ),
  250. 20: dict(
  251. name='Cavalieri',
  252. coordinates=(43.719856, 10.400194),
  253. ),
  254. 21: dict(
  255. name='M. Libertà',
  256. coordinates=(43.719821, 10.403021),
  257. ),
  258. 22: dict(
  259. name='Galleria Gerace',
  260. coordinates=(43.710791, 10.420456),
  261. ),
  262. 23: dict(
  263. name='C. Marchesi',
  264. coordinates=(43.714971, 10.419322),
  265. ),
  266. 24: dict(
  267. name='CNR-Praticelli',
  268. coordinates=(43.719256, 10.424012),
  269. ),
  270. 25: dict(
  271. name='Sesta Porta',
  272. coordinates=(43.709162, 10.395837),
  273. ),
  274. 26: dict(
  275. name='Qualconia',
  276. coordinates=(43.713011, 10.394458),
  277. ),
  278. 27: dict(
  279. name='Donatello',
  280. coordinates=(43.711715, 10.372480),
  281. ),
  282. 28: dict(
  283. name='Spadoni',
  284. coordinates=(43.716850, 10.391347),
  285. ),
  286. 29: dict(
  287. name='Nievo',
  288. coordinates=(43.738286, 10.400865),
  289. ),
  290. 30: dict(
  291. name='Cisanello',
  292. coordinates=(43.701159, 10.438863),
  293. ),
  294. 31: dict(
  295. name='Edificio 3',
  296. coordinates=(43.707869, 10.441698),
  297. ),
  298. 32: dict(
  299. name='Edificio 6',
  300. coordinates=(43.709046, 10.442541),
  301. ),
  302. 33: dict(
  303. name='Frascani',
  304. coordinates=(43.710157, 10.433339),
  305. ),
  306. 34: dict(
  307. name='Chiarugi',
  308. coordinates=(43.726244, 10.412882),
  309. ),
  310. 35: dict(
  311. name='Praticelli 2',
  312. coordinates=(43.719619, 10.427469),
  313. ),
  314. 36: dict(
  315. name='Carducci',
  316. coordinates=(43.726700, 10.420562),
  317. ),
  318. 37: dict(
  319. name='Garibaldi',
  320. coordinates=(43.718077, 10.418168),
  321. ),
  322. 38: dict(
  323. name='Silvestro',
  324. coordinates=(43.714128, 10.409065),
  325. ),
  326. 39: dict(
  327. name='Pardi',
  328. coordinates=(43.702273, 10.399793),
  329. ),
  330. }
  331. def __init__(self, id=0, name='unknown', coordinates=(91.0, 181.0)):
  332. """Check and set instance attributes."""
  333. if id in self.__class__.stations:
  334. coordinates = self.__class__.stations[id]['coordinates']
  335. name = self.__class__.stations[id]['name']
  336. Location.__init__(self, coordinates)
  337. self._id = id
  338. self._name = name
  339. self._active = True
  340. self._location = None
  341. self._description = ''
  342. self._distance = None
  343. self._bikes = 0
  344. self._free = 0
  345. @property
  346. def id(self):
  347. """Return station identification number."""
  348. return self._id
  349. @property
  350. def name(self):
  351. """Return station name."""
  352. return self._name
  353. @property
  354. def description(self):
  355. """Return station description."""
  356. return self._description
  357. @property
  358. def is_active(self):
  359. """Return True if station is active."""
  360. return self._active
  361. @property
  362. def location(self):
  363. """Return location from which distance should be evaluated."""
  364. if self._location is None:
  365. return default_location
  366. return self._location
  367. @property
  368. def distance(self):
  369. """Return distance from `self.location`.
  370. If distance is not evaluated yet, do it and store the result.
  371. Otherwise, return stored value.
  372. """
  373. if self._distance is None:
  374. self._distance = self.get_distance(self.location)
  375. return self._distance
  376. @property
  377. def bikes(self):
  378. """Return number of available bikes."""
  379. return self._bikes
  380. @property
  381. def free(self):
  382. """Return number of free slots."""
  383. return self._free
  384. def set_active(self, active):
  385. """Change station status to `active`.
  386. `active` should be either `True` or `False`.
  387. """
  388. assert type(active) is bool, "`active` should be a boolean."
  389. self._active = active
  390. def set_description(self, description):
  391. """Change station description to `description`.
  392. `description` should be a string.
  393. """
  394. assert type(description) is str, "`description` should be a boolean."
  395. self._description = description
  396. def set_location(self, location):
  397. """Change station location to `location`.
  398. `location` should be a Location object.
  399. """
  400. assert (
  401. isinstance(location, Location)
  402. ), "`location` should be a Location."
  403. self._location = location
  404. def set_bikes(self, bikes):
  405. """Change number of available `bikes`.
  406. `bikes` should be an int.
  407. """
  408. assert (
  409. type(bikes) is int
  410. ), "`bikes` should be an int."
  411. self._bikes = bikes
  412. def set_free(self, free):
  413. """Change number of `free` bike parking slots.
  414. `free` should be an int.
  415. """
  416. assert (
  417. type(free) is int
  418. ), "`free` should be an int."
  419. self._free = free
  420. @property
  421. def status(self):
  422. """Return station status to be shown to users.
  423. It includes distance, location, available bikes and free stalls.
  424. """
  425. if self.bikes + self.free == 0:
  426. bikes_and_stalls = "<i>⚠️ Non disponibile</i>"
  427. else:
  428. bikes_and_stalls = f"🚲 {self.bikes} | 🅿️ {self.free}"
  429. return (
  430. f"<b>{self.name}</b> | <i>{self.description}</i>\n"
  431. f"<code> </code>{bikes_and_stalls} | 📍 {self.distance:.0f} m"
  432. ).format(
  433. s=self
  434. )
  435. def ciclopi_custom_sorter(custom_order):
  436. """Return a function to sort stations by a `custom_order`."""
  437. custom_values = {
  438. record['station']: record['value']
  439. for record in custom_order
  440. }
  441. def sorter(station):
  442. """Take a station and return its queue value.
  443. Stations will be sorted by queue value in ascending order.
  444. """
  445. if station.id in custom_values:
  446. return (custom_values[station.id], station.name)
  447. return (100, station.name)
  448. return sorter
  449. def _get_stations(data, location):
  450. stations = []
  451. for _station in data.find_all(
  452. "li",
  453. attrs={"class": "rrItem"}
  454. ):
  455. station_name = _station.find(
  456. "span",
  457. attrs={"class": "Stazione"}
  458. ).text
  459. if 'Non operativa' in station_name:
  460. active = False
  461. else:
  462. active = True
  463. station_id = _station.find(
  464. "div",
  465. attrs={"class": "cssNumero"}
  466. ).text
  467. if (
  468. station_id is None
  469. or type(station_id) is not str
  470. or not station_id.isnumeric()
  471. ):
  472. station_id = 0
  473. else:
  474. station_id = int(station_id)
  475. station = Station(station_id)
  476. station.set_active(active)
  477. station.set_description(
  478. _station.find(
  479. "span",
  480. attrs={"class": "TableComune"}
  481. ).text.replace(
  482. 'a`',
  483. 'à'
  484. ).replace(
  485. 'e`',
  486. 'è'
  487. )
  488. )
  489. bikes_text = _station.find(
  490. "span",
  491. attrs={"class": "Red"}
  492. ).get_text('\t')
  493. if bikes_text.count('\t') < 1:
  494. bikes = 0
  495. free = 0
  496. else:
  497. bikes, free, *other = [
  498. int(
  499. ''.join(
  500. char
  501. for char in s
  502. if char.isnumeric()
  503. )
  504. )
  505. for s in bikes_text.split('\t')
  506. ]
  507. station.set_bikes(bikes)
  508. station.set_free(free)
  509. station.set_location(location)
  510. stations.append(
  511. station
  512. )
  513. return stations
  514. async def set_ciclopi_location(bot, update, user_record):
  515. """Take a location update and store it as CicloPi place.
  516. CicloPi stations will be sorted by distance from this place.
  517. """
  518. location = update['location']
  519. chat_id = update['chat']['id']
  520. telegram_id = update['from']['id']
  521. with bot.db as db:
  522. db['ciclopi'].upsert(
  523. dict(
  524. chat_id=chat_id,
  525. latitude=location['latitude'],
  526. longitude=location['longitude']
  527. ),
  528. ['chat_id']
  529. )
  530. await bot.send_message(
  531. chat_id=chat_id,
  532. text=(
  533. "Ho salvato questa posizione!\n"
  534. "D'ora in poi ordinerò le stazioni dalla più vicina alla più "
  535. "lontana da qui."
  536. )
  537. )
  538. # Remove individual text message handler which was set to catch `/cancel`
  539. bot.remove_individual_text_message_handler(telegram_id)
  540. return await _ciclopi_command(bot, update, user_record)
  541. async def cancel_ciclopi_location(bot, update, user_record):
  542. """Handle the situation in which a user does not send location on request.
  543. This function is set as individual text message handler when the bot
  544. requests user's location and is removed if user does send one.
  545. If not, return a proper message.
  546. """
  547. text = get_cleaned_text(bot=bot, update=update)
  548. # If user cancels operation, confirm that it was cancelled
  549. if text.lower() == 'annulla':
  550. return "Operazione annullata."
  551. # If user writes something else, remind them how to set position later
  552. return (
  553. "Non ho capito la tua posizione. Fai /ciclopi > Ordina... > "
  554. "Posizione 🧭 per riprovare."
  555. )
  556. async def _ciclopi_command(bot, update, user_record, sent_message=None,
  557. show_all=False):
  558. chat_id = update['chat']['id']
  559. default_stations_to_show = 5
  560. if sent_message is None:
  561. await bot.sendChatAction(
  562. chat_id=chat_id,
  563. action='typing'
  564. )
  565. else:
  566. await bot.edit_message_text(
  567. update=sent_message,
  568. text="<i>Aggiornamento in corso...</i>",
  569. parse_mode='HTML',
  570. reply_markup=None
  571. )
  572. ciclopi_data = await ciclopi_webpage.get_page()
  573. if ciclopi_data is None or isinstance(ciclopi_data, Exception):
  574. text = (
  575. "Il sito del CicloPi è momentaneamente irraggiungibile, "
  576. "riprova tra un po' :/"
  577. )
  578. else:
  579. with bot.db as db:
  580. ciclopi_record = db['ciclopi'].find_one(
  581. chat_id=chat_id
  582. )
  583. custom_order = list(
  584. db['ciclopi_custom_order'].find(
  585. chat_id=chat_id
  586. )
  587. )
  588. if (
  589. ciclopi_record is not None
  590. and isinstance(ciclopi_record, dict)
  591. and 'sorting' in ciclopi_record
  592. and ciclopi_record['sorting'] in CICLOPI_SORTING_CHOICES
  593. ):
  594. sorting_code = ciclopi_record['sorting']
  595. if (
  596. 'latitude' in ciclopi_record
  597. and ciclopi_record['latitude'] is not None
  598. and 'longitude' in ciclopi_record
  599. and ciclopi_record['longitude'] is not None
  600. ):
  601. saved_place = Location(
  602. (
  603. ciclopi_record['latitude'],
  604. ciclopi_record['longitude']
  605. )
  606. )
  607. else:
  608. saved_place = default_location
  609. else:
  610. sorting_code = 0
  611. if (
  612. ciclopi_record is not None
  613. and isinstance(ciclopi_record, dict)
  614. and 'stations_to_show' in ciclopi_record
  615. and ciclopi_record[
  616. 'stations_to_show'
  617. ] in CICLOPI_STATIONS_TO_SHOW
  618. ):
  619. stations_to_show = ciclopi_record[
  620. 'stations_to_show'
  621. ]
  622. else:
  623. stations_to_show = default_stations_to_show
  624. location = (
  625. saved_place if sorting_code != 0
  626. else default_location
  627. )
  628. sorting_method = (
  629. (lambda station: station.distance) if sorting_code in [0, 2]
  630. else (lambda station: station.name) if sorting_code == 1
  631. else ciclopi_custom_sorter(custom_order) if sorting_code == 3
  632. else (lambda station: 0)
  633. )
  634. stations = sorted(
  635. _get_stations(
  636. ciclopi_data,
  637. location
  638. ),
  639. key=sorting_method
  640. )
  641. if (
  642. stations_to_show == -1
  643. and not show_all
  644. ):
  645. stations = list(
  646. filter(
  647. lambda station: station.id in [
  648. record['station']
  649. for record in custom_order
  650. ],
  651. stations
  652. )
  653. )
  654. if (
  655. stations_to_show > 0
  656. and sorting_code != 1
  657. and not show_all
  658. ):
  659. stations = stations[:stations_to_show]
  660. text = (
  661. "🚲 Stazioni ciclopi {sort[short_description]}"
  662. "{lim} {sort[symbol]}\n"
  663. "\n"
  664. "{s}"
  665. ).format(
  666. s=(
  667. '\n\n'.join(
  668. station.status
  669. for station in stations
  670. ) if len(stations)
  671. else "<i>- Nessuna stazione -</i>"
  672. ),
  673. sort=CICLOPI_SORTING_CHOICES[sorting_code],
  674. lim=(
  675. " ({adv} le preferite)".format(
  676. adv='prima' if show_all else 'solo'
  677. ) if stations_to_show == -1
  678. else " (prime {n})".format(
  679. n=stations_to_show
  680. )
  681. if len(stations) < len(Station.stations)
  682. else ""
  683. )
  684. )
  685. if not text:
  686. return
  687. reply_markup = make_inline_keyboard(
  688. (
  689. [
  690. make_button(
  691. "💯 Tutte",
  692. prefix='ciclopi:///',
  693. data=['show', 'all']
  694. )
  695. ] if len(stations) < len(Station.stations)
  696. else [
  697. make_button(
  698. "{sy[symbol]} {t}".format(
  699. t=(
  700. "Solo preferite" if stations_to_show == -1
  701. else "Prime {n}"
  702. ).format(
  703. n=stations_to_show
  704. ),
  705. sy=CICLOPI_STATIONS_TO_SHOW[stations_to_show]
  706. ),
  707. prefix='ciclopi:///',
  708. data=['show']
  709. )
  710. ] if show_all
  711. else []
  712. ) + [
  713. make_button(
  714. "🔄 Aggiorna",
  715. prefix='ciclopi:///',
  716. data=(
  717. ['show'] + (
  718. [] if len(stations) < len(Station.stations)
  719. else ['all']
  720. )
  721. )
  722. ),
  723. make_button(
  724. "📜 Legenda",
  725. prefix='ciclopi:///',
  726. data=['legend']
  727. ),
  728. make_button(
  729. "⚙️ Impostazioni",
  730. prefix='ciclopi:///',
  731. data=['main']
  732. )
  733. ],
  734. 2
  735. )
  736. parameters = dict(
  737. update=update,
  738. text=text,
  739. parse_mode='HTML',
  740. reply_markup=reply_markup
  741. )
  742. method = (
  743. bot.send_message
  744. if sent_message is None
  745. else bot.edit_message_text
  746. )
  747. await method(**parameters)
  748. return
  749. async def _ciclopi_button_main(bot, update, user_record, arguments):
  750. result, text, reply_markup = '', '', None
  751. text = (
  752. "⚙️ Impostazioni CicloPi 🚲\n"
  753. "\n"
  754. "{c}"
  755. ).format(
  756. c='\n'.join(
  757. "- {s[symbol]} {s[name]}: {s[description]}".format(
  758. s=setting
  759. )
  760. for setting in CICLOPI_SETTINGS.values()
  761. )
  762. )
  763. reply_markup = make_inline_keyboard(
  764. [
  765. make_button(
  766. text="{s[symbol]} {s[name]}".format(
  767. s=setting
  768. ),
  769. prefix='ciclopi:///',
  770. data=[code]
  771. )
  772. for code, setting in CICLOPI_SETTINGS.items()
  773. ] + [
  774. make_button(
  775. text="🚲 Torna alle stazioni",
  776. prefix='ciclopi:///',
  777. data=['show']
  778. )
  779. ]
  780. )
  781. return result, text, reply_markup
  782. async def _ciclopi_button_sort(bot, update, user_record, arguments):
  783. result, text, reply_markup = '', '', None
  784. chat_id = (
  785. update['message']['chat']['id'] if 'message' in update
  786. else update['chat']['id'] if 'chat' in update
  787. else 0
  788. )
  789. with bot.db as db:
  790. ciclopi_record = db['ciclopi'].find_one(
  791. chat_id=chat_id
  792. )
  793. if ciclopi_record is None:
  794. ciclopi_record = dict(
  795. chat_id=chat_id,
  796. sorting=0
  797. )
  798. if len(arguments) == 1:
  799. new_choice = (
  800. int(arguments[0])
  801. if arguments[0].isnumeric()
  802. else 0
  803. )
  804. if new_choice == ciclopi_record['sorting']:
  805. return "È già così!", '', None
  806. elif new_choice not in CICLOPI_SORTING_CHOICES:
  807. return "Opzione sconosciuta!", '', None
  808. db['ciclopi'].upsert(
  809. dict(
  810. chat_id=chat_id,
  811. sorting=new_choice
  812. ),
  813. ['chat_id'],
  814. ensure=True
  815. )
  816. ciclopi_record['sorting'] = new_choice
  817. result = "Fatto!"
  818. text = (
  819. "📜 Modalità di visualizzazione delle stazioni CicloPi 🚲\n\n"
  820. "{options}\n\n"
  821. "Scegli una nuova modalità o torna all'elenco delle stazioni."
  822. ).format(
  823. options='\n'.join(
  824. "- {c[symbol]} {c[name]}: {c[description]}".format(
  825. c=choice
  826. )
  827. for choice in CICLOPI_SORTING_CHOICES.values()
  828. )
  829. )
  830. reply_markup = make_inline_keyboard(
  831. [
  832. make_button(
  833. text="{s} {c[name]} {c[symbol]}".format(
  834. c=choice,
  835. s=(
  836. '✅'
  837. if code == ciclopi_record['sorting']
  838. else '☑️'
  839. )
  840. ),
  841. prefix='ciclopi:///',
  842. data=['sort', code]
  843. )
  844. for code, choice in CICLOPI_SORTING_CHOICES.items()
  845. ] + [
  846. make_button(
  847. text="⚙️ Torna alle impostazioni",
  848. prefix='ciclopi:///',
  849. data=['main']
  850. ),
  851. make_button(
  852. text="🚲 Torna alle stazioni",
  853. prefix='ciclopi:///',
  854. data=['show']
  855. )
  856. ]
  857. )
  858. return result, text, reply_markup
  859. async def _ciclopi_button_limit(bot, update, user_record, arguments):
  860. result, text, reply_markup = '', '', None
  861. chat_id = (
  862. update['message']['chat']['id'] if 'message' in update
  863. else update['chat']['id'] if 'chat' in update
  864. else 0
  865. )
  866. with bot.db as db:
  867. ciclopi_record = db['ciclopi'].find_one(
  868. chat_id=chat_id
  869. )
  870. if ciclopi_record is None or 'stations_to_show' not in ciclopi_record:
  871. ciclopi_record = dict(
  872. chat_id=chat_id,
  873. stations_to_show=5
  874. )
  875. if len(arguments) == 1:
  876. new_choice = (
  877. int(arguments[0])
  878. if arguments[0].lstrip('+-').isnumeric()
  879. else 0
  880. )
  881. if new_choice == ciclopi_record['stations_to_show']:
  882. return "È già così!", '', None
  883. elif new_choice not in CICLOPI_STATIONS_TO_SHOW:
  884. return "Opzione sconosciuta!", '', None
  885. db['ciclopi'].upsert(
  886. dict(
  887. chat_id=chat_id,
  888. stations_to_show=new_choice
  889. ),
  890. ['chat_id'],
  891. ensure=True
  892. )
  893. ciclopi_record['stations_to_show'] = new_choice
  894. result = "Fatto!"
  895. text = (
  896. "📜 Modalità di visualizzazione delle stazioni CicloPi 🚲\n\n"
  897. "{options}\n\n"
  898. "Scegli quante stazioni vedere (quando filtrate per distanza) o torna "
  899. "alle impostazioni o all'elenco delle stazioni."
  900. ).format(
  901. options='\n'.join(
  902. "- {c[symbol]} {c[name]}".format(
  903. c=choice
  904. )
  905. for choice in CICLOPI_STATIONS_TO_SHOW.values()
  906. )
  907. )
  908. reply_markup = make_inline_keyboard(
  909. [
  910. make_button(
  911. text="{s} {c[name]} {c[symbol]}".format(
  912. c=choice,
  913. s=(
  914. '✅'
  915. if code == ciclopi_record['stations_to_show']
  916. else '☑️'
  917. )
  918. ),
  919. prefix='ciclopi:///',
  920. data=['limit', code]
  921. )
  922. for code, choice in CICLOPI_STATIONS_TO_SHOW.items()
  923. ] + [
  924. make_button(
  925. text="⚙️ Torna alle impostazioni",
  926. prefix='ciclopi:///',
  927. data=['main']
  928. ),
  929. make_button(
  930. text="🚲 Torna alle stazioni",
  931. prefix='ciclopi:///',
  932. data=['show']
  933. )
  934. ]
  935. )
  936. return result, text, reply_markup
  937. async def _ciclopi_button_show(bot, update, user_record, arguments):
  938. result, text, reply_markup = '', '', None
  939. fake_update = update['message']
  940. fake_update['from'] = update['from']
  941. asyncio.ensure_future(
  942. _ciclopi_command(
  943. bot=bot,
  944. update=fake_update,
  945. user_record=user_record,
  946. sent_message=fake_update,
  947. show_all=(
  948. True if len(arguments) == 1 and arguments[0] == 'all'
  949. else False
  950. )
  951. )
  952. )
  953. return result, text, reply_markup
  954. async def _ciclopi_button_legend(bot, update, user_record, arguments):
  955. result, text, reply_markup = '', '', None
  956. text = (
  957. "<b>{s[name]}</b> | <i>{s[description]}</i>\n"
  958. "<code> </code>🚲 {s[bikes]} | 🅿️ {s[free]} | 📍 {s[distance]}"
  959. ).format(
  960. s={
  961. 'name': "Nome della stazione",
  962. 'distance': "Distanza in m",
  963. 'description': "Indirizzo della stazione",
  964. 'bikes': "Bici disponibili",
  965. 'free': "Posti liberi"
  966. }
  967. )
  968. reply_markup = make_inline_keyboard(
  969. [
  970. make_button(
  971. text="⚙️ Torna alle impostazioni",
  972. prefix='ciclopi:///',
  973. data=['main']
  974. ),
  975. make_button(
  976. text="🚲 Torna alle stazioni",
  977. prefix='ciclopi:///',
  978. data=['show']
  979. )
  980. ]
  981. )
  982. return result, text, reply_markup
  983. async def _ciclopi_button_favorites_add(bot, update, user_record, arguments,
  984. order_record, ordered_stations):
  985. result, text, reply_markup = '', '', None
  986. result = "Seleziona le stazioni da aggiungere"
  987. if len(arguments) == 2 and arguments[1].isnumeric():
  988. station_id = int(arguments[1])
  989. chat_id = (
  990. update['message']['chat']['id'] if 'message' in update
  991. else update['chat']['id'] if 'chat' in update
  992. else 0
  993. )
  994. with bot.db as db:
  995. if station_id in (s.id for s in ordered_stations): # Remove
  996. # Find `old_record` to be removed
  997. for old_record in order_record:
  998. if old_record['station'] == station_id:
  999. break
  1000. db.query(
  1001. """UPDATE ciclopi_custom_order
  1002. SET value = value - 1
  1003. WHERE chat_id = {chat_id}
  1004. AND value > {val}
  1005. """.format(
  1006. chat_id=chat_id,
  1007. val=old_record['value']
  1008. )
  1009. )
  1010. db['ciclopi_custom_order'].delete(
  1011. id=old_record['id']
  1012. )
  1013. order_record = list(
  1014. filter(
  1015. (lambda r: r['station'] != station_id),
  1016. order_record
  1017. )
  1018. )
  1019. ordered_stations = list(
  1020. filter(
  1021. (lambda s: s.id != station_id),
  1022. ordered_stations
  1023. )
  1024. )
  1025. else: # Add
  1026. new_record = dict(
  1027. chat_id=chat_id,
  1028. station=station_id,
  1029. value=(len(order_record) + 1)
  1030. )
  1031. db['ciclopi_custom_order'].upsert(
  1032. new_record,
  1033. ['chat_id', 'station'],
  1034. ensure=True
  1035. )
  1036. order_record.append(new_record)
  1037. ordered_stations.append(
  1038. Station(station_id)
  1039. )
  1040. text = (
  1041. "🚲 <b>Stazioni preferite</b> ⭐️\n"
  1042. "{options}\n\n"
  1043. "Aggiungi o togli le tue stazioni preferite."
  1044. ).format(
  1045. options=line_drawing_unordered_list(
  1046. [
  1047. station.name
  1048. for station in ordered_stations
  1049. ]
  1050. )
  1051. )
  1052. reply_markup = dict(
  1053. inline_keyboard=make_lines_of_buttons(
  1054. [
  1055. make_button(
  1056. text=(
  1057. "{sy} {n}"
  1058. ).format(
  1059. sy=(
  1060. '✅' if station_id in [
  1061. s.id for s in ordered_stations
  1062. ]
  1063. else '☑️'
  1064. ),
  1065. n=station['name']
  1066. ),
  1067. prefix='ciclopi:///',
  1068. data=['fav', 'add', station_id]
  1069. )
  1070. for station_id, station in sorted(
  1071. Station.stations.items(),
  1072. key=lambda t: t[1]['name'] # Sort by station_name
  1073. )
  1074. ],
  1075. 3
  1076. ) + make_lines_of_buttons(
  1077. [
  1078. make_button(
  1079. text="🔃 Riordina",
  1080. prefix="ciclopi:///",
  1081. data=["fav"]
  1082. ),
  1083. make_button(
  1084. text="⚙️ Torna alle impostazioni",
  1085. prefix='ciclopi:///',
  1086. data=['main']
  1087. ),
  1088. make_button(
  1089. text="🚲 Torna alle stazioni",
  1090. prefix='ciclopi:///',
  1091. data=['show']
  1092. )
  1093. ],
  1094. 3
  1095. )
  1096. )
  1097. return result, text, reply_markup
  1098. def move_favorite_station(
  1099. bot, chat_id, action, station_id,
  1100. order_record
  1101. ):
  1102. """Move a station in `chat_id`-associated custom order.
  1103. `bot`: Bot object, having a `.db` property.
  1104. `action`: should be `up` or `down`
  1105. `order_record`: list of records about `chat_id`-associated custom order.
  1106. """
  1107. assert action in ('up', 'down'), "Invalid action!"
  1108. for old_record in order_record:
  1109. if old_record['station'] == station_id:
  1110. break
  1111. with bot.db as db:
  1112. if action == 'down':
  1113. db.query(
  1114. """UPDATE ciclopi_custom_order
  1115. SET value = 500
  1116. WHERE chat_id = {chat_id}
  1117. AND value = {val} + 1
  1118. """.format(
  1119. chat_id=chat_id,
  1120. val=old_record['value']
  1121. )
  1122. )
  1123. db.query(
  1124. """UPDATE ciclopi_custom_order
  1125. SET value = value + 1
  1126. WHERE chat_id = {chat_id}
  1127. AND value = {val}
  1128. """.format(
  1129. chat_id=chat_id,
  1130. val=old_record['value']
  1131. )
  1132. )
  1133. db.query(
  1134. """UPDATE ciclopi_custom_order
  1135. SET value = {val}
  1136. WHERE chat_id = {chat_id}
  1137. AND value = 500
  1138. """.format(
  1139. chat_id=chat_id,
  1140. val=old_record['value']
  1141. )
  1142. )
  1143. elif action == 'up':
  1144. db.query(
  1145. """UPDATE ciclopi_custom_order
  1146. SET value = 500
  1147. WHERE chat_id = {chat_id}
  1148. AND value = {val} - 1
  1149. """.format(
  1150. chat_id=chat_id,
  1151. val=old_record['value']
  1152. )
  1153. )
  1154. db.query(
  1155. """UPDATE ciclopi_custom_order
  1156. SET value = value - 1
  1157. WHERE chat_id = {chat_id}
  1158. AND value = {val}
  1159. """.format(
  1160. chat_id=chat_id,
  1161. val=old_record['value']
  1162. )
  1163. )
  1164. db.query(
  1165. """UPDATE ciclopi_custom_order
  1166. SET value = {val}
  1167. WHERE chat_id = {chat_id}
  1168. AND value = 500
  1169. """.format(
  1170. chat_id=chat_id,
  1171. val=old_record['value']
  1172. )
  1173. )
  1174. order_record = list(
  1175. db['ciclopi_custom_order'].find(
  1176. chat_id=chat_id,
  1177. order_by=['value']
  1178. )
  1179. )
  1180. ordered_stations = [
  1181. Station(record['station'])
  1182. for record in order_record
  1183. ]
  1184. return order_record, ordered_stations
  1185. async def _ciclopi_button_favorites(bot, update, user_record, arguments):
  1186. result, text, reply_markup = '', '', None
  1187. action = (
  1188. arguments[0] if len(arguments) > 0
  1189. else 'up'
  1190. )
  1191. chat_id = (
  1192. update['message']['chat']['id'] if 'message' in update
  1193. else update['chat']['id'] if 'chat' in update
  1194. else 0
  1195. )
  1196. with bot.db as db:
  1197. order_record = list(
  1198. db['ciclopi_custom_order'].find(
  1199. chat_id=chat_id,
  1200. order_by=['value']
  1201. )
  1202. )
  1203. ordered_stations = [
  1204. Station(record['station'])
  1205. for record in order_record
  1206. ]
  1207. if action == 'add':
  1208. return await _ciclopi_button_favorites_add(
  1209. bot, update, user_record, arguments,
  1210. order_record, ordered_stations
  1211. )
  1212. elif action == 'dummy':
  1213. return 'Capolinea!', '', None
  1214. elif action == 'set' and len(arguments) > 1:
  1215. action = arguments[1]
  1216. elif (
  1217. action in ['up', 'down']
  1218. and len(arguments) > 1
  1219. and arguments[1].isnumeric()
  1220. ):
  1221. station_id = int(arguments[1])
  1222. order_record, ordered_stations = move_favorite_station(
  1223. bot, chat_id, action, station_id,
  1224. order_record
  1225. )
  1226. text = (
  1227. "🚲 <b>Stazioni preferite</b> ⭐️\n"
  1228. "{options}\n\n"
  1229. "Aggiungi, togli o riordina le tue stazioni preferite."
  1230. ).format(
  1231. options=line_drawing_unordered_list(
  1232. [
  1233. station.name
  1234. for station in ordered_stations
  1235. ]
  1236. )
  1237. )
  1238. reply_markup = dict(
  1239. inline_keyboard=[
  1240. [
  1241. make_button(
  1242. text="{s.name} {sy}".format(
  1243. sy=(
  1244. '⬆️' if (
  1245. action == 'up'
  1246. and n != 1
  1247. ) else '⬇️' if (
  1248. action == 'down'
  1249. and n != len(ordered_stations)
  1250. ) else '⏹'
  1251. ),
  1252. s=station
  1253. ),
  1254. prefix='ciclopi:///',
  1255. data=[
  1256. 'fav',
  1257. (
  1258. action if (
  1259. action == 'up'
  1260. and n != 1
  1261. ) or (
  1262. action == 'down'
  1263. and n != len(ordered_stations)
  1264. )
  1265. else 'dummy'
  1266. ),
  1267. station.id
  1268. ]
  1269. )
  1270. ]
  1271. for n, station in enumerate(ordered_stations, 1)
  1272. ] + [
  1273. [
  1274. make_button(
  1275. text="➕ Aggiungi stazione preferita ⭐️",
  1276. prefix='ciclopi:///',
  1277. data=['fav', 'add']
  1278. )
  1279. ]
  1280. ] + [
  1281. [
  1282. (
  1283. make_button(
  1284. text='Sposta in basso ⬇️',
  1285. prefix='ciclopi:///',
  1286. data=['fav', 'set', 'down']
  1287. ) if action == 'up'
  1288. else make_button(
  1289. text='Sposta in alto ⬆️',
  1290. prefix='ciclopi:///',
  1291. data=['fav', 'set', 'up']
  1292. )
  1293. )
  1294. ]
  1295. ] + [
  1296. [
  1297. make_button(
  1298. text="⚙️ Torna alle impostazioni",
  1299. prefix='ciclopi:///',
  1300. data=['main']
  1301. ),
  1302. make_button(
  1303. text="🚲 Torna alle stazioni",
  1304. prefix='ciclopi:///',
  1305. data=['show']
  1306. )
  1307. ]
  1308. ]
  1309. )
  1310. return result, text, reply_markup
  1311. async def _ciclopi_button_setpos(bot, update, user_record, arguments):
  1312. result, text, reply_markup = '', '', None
  1313. chat_id = (
  1314. update['message']['chat']['id'] if 'message' in update
  1315. else update['chat']['id'] if 'chat' in update
  1316. else 0
  1317. )
  1318. result = "Inviami una posizione!"
  1319. bot.set_individual_location_handler(
  1320. await async_wrapper(
  1321. set_ciclopi_location
  1322. ),
  1323. update
  1324. )
  1325. bot.set_individual_text_message_handler(
  1326. cancel_ciclopi_location,
  1327. update
  1328. )
  1329. asyncio.ensure_future(
  1330. bot.send_message(
  1331. chat_id=chat_id,
  1332. text=(
  1333. "Inviami una posizione.\n"
  1334. "Per inviare la tua posizione attuale, usa il "
  1335. "pulsante."
  1336. ),
  1337. reply_markup=dict(
  1338. keyboard=[
  1339. [
  1340. dict(
  1341. text="Invia la mia posizione",
  1342. request_location=True
  1343. )
  1344. ],
  1345. [
  1346. dict(
  1347. text="Annulla"
  1348. )
  1349. ]
  1350. ],
  1351. resize_keyboard=True
  1352. )
  1353. )
  1354. )
  1355. return result, text, reply_markup
  1356. _ciclopi_button_routing_table = {
  1357. 'main': _ciclopi_button_main,
  1358. 'sort': _ciclopi_button_sort,
  1359. 'limit': _ciclopi_button_limit,
  1360. 'show': _ciclopi_button_show,
  1361. 'setpos': _ciclopi_button_setpos,
  1362. 'legend': _ciclopi_button_legend,
  1363. 'fav': _ciclopi_button_favorites
  1364. }
  1365. async def _ciclopi_button(bot, update, user_record):
  1366. data = update['data']
  1367. command, *arguments = extract(data, ':///').split('|')
  1368. if command in _ciclopi_button_routing_table:
  1369. result, text, reply_markup = await _ciclopi_button_routing_table[
  1370. command
  1371. ](
  1372. bot, update, user_record, arguments
  1373. )
  1374. else:
  1375. return
  1376. if text:
  1377. return dict(
  1378. text=result,
  1379. edit=dict(
  1380. text=text,
  1381. parse_mode='HTML',
  1382. reply_markup=reply_markup
  1383. )
  1384. )
  1385. return result
  1386. def init(bot, ciclopi_messages=None):
  1387. """Take a bot and assign commands to it."""
  1388. with bot.db as db:
  1389. if 'ciclopi_stations' not in db.tables:
  1390. db['ciclopi_stations'].insert_many(
  1391. sorted(
  1392. [
  1393. dict(
  1394. station_id=station_id,
  1395. name=station['name'],
  1396. latitude=station['coordinates'][0],
  1397. longitude=station['coordinates'][1]
  1398. )
  1399. for station_id, station in Station.stations.items()
  1400. ],
  1401. key=(lambda station: station['station_id'])
  1402. )
  1403. )
  1404. if 'ciclopi' not in db.tables:
  1405. db['ciclopi'].insert(
  1406. dict(
  1407. chat_id=0,
  1408. sorting=0,
  1409. latitude=0.0,
  1410. longitude=0.0,
  1411. stations_to_show=-1
  1412. )
  1413. )
  1414. if ciclopi_messages is None:
  1415. ciclopi_messages = default_ciclopi_messages
  1416. @bot.command(command='/ciclopi', aliases=["CicloPi 🚲", "🚲 CicloPi 🔴"],
  1417. reply_keyboard_button=ciclopi_messages[
  1418. 'ciclopi_command']['reply_keyboard_button'],
  1419. show_in_keyboard=True,
  1420. description=(
  1421. ciclopi_messages['ciclopi_command']['description']
  1422. ),
  1423. authorization_level='everybody')
  1424. async def ciclopi_command(bot, update, user_record):
  1425. return await _ciclopi_command(bot, update, user_record)
  1426. @bot.button(prefix='ciclopi:///', authorization_level='everybody')
  1427. async def ciclopi_button(bot, update, user_record):
  1428. return await _ciclopi_button(bot, update, user_record)