fetch_tickets.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. #!/usr/bin/env python3
  2. """
  3. Fetches new tickets (receipts) from the Toqla/Sodexo platform and sends them
  4. to a Telegram chat.
  5. The list of receipts is retrieved from:
  6. GET /api/2.0/users/badges/<badge_uuid>/receipts?start-date=...&end-date=...
  7. Each receipt's detail (a printable HTML ticket) is retrieved from:
  8. GET /api/2.0/users/badges/<badge_uuid>/receipts/<id>
  9. The <badge_uuid> is specific to the user and is discovered automatically from:
  10. GET /api/2.0/users/badges
  11. Only tickets newer than the last one processed are sent (tracked in
  12. last_ticket.json, next to this script). If no reference exists yet (first
  13. run), only the single most recent ticket is processed.
  14. Authentication is shared with fetch_menus.py via toqla_auth.py (see that
  15. module's docstring for how to provide Auth0 session cookies).
  16. Usage:
  17. python3 fetch_tickets.py
  18. python3 fetch_tickets.py --cookies "did=...; auth0=..."
  19. python3 fetch_tickets.py --cookies-file cookies.txt
  20. python3 fetch_tickets.py --dry-run # print instead of sending to Telegram
  21. """
  22. import argparse
  23. import html
  24. import json
  25. import logging
  26. import os
  27. import re
  28. import sys
  29. import time
  30. from datetime import datetime, timedelta, timezone
  31. from pathlib import Path
  32. import requests
  33. import toqla_auth
  34. # ---------------------------------------------------------------------------
  35. # Config (edit as needed)
  36. # ---------------------------------------------------------------------------
  37. LOOKBACK_DAYS = 30 # how many days back to look for tickets on each run
  38. LOCAL_UTC_OFFSET = timezone(timedelta(hours=2)) # Europe/Paris (adjust for DST if needed)
  39. LAST_TICKET_FILE = "last_ticket.json"
  40. TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "<PUT_TELEGRAM_BOT_TOKEN_HERE>")
  41. TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "<PUT_TELEGRAM_CHAT_ID_HERE>")
  42. TELEGRAM_API_URL = "https://api.telegram.org/bot{token}/sendMessage"
  43. TELEGRAM_MAX_LEN = 4096
  44. logging.basicConfig(
  45. level=logging.INFO,
  46. format="%(asctime)s [%(levelname)s] %(message)s",
  47. handlers=[logging.StreamHandler(sys.stdout)],
  48. )
  49. log = logging.getLogger(__name__)
  50. # ---------------------------------------------------------------------------
  51. # Toqla API calls
  52. # ---------------------------------------------------------------------------
  53. def _list_from(data: object, *keys: str) -> list:
  54. if isinstance(data, list):
  55. return data
  56. if isinstance(data, dict):
  57. for k in keys:
  58. if isinstance(data.get(k), list):
  59. return data[k]
  60. return []
  61. def fetch_badge_uuid(session: requests.Session) -> tuple[str, str]:
  62. """Discover the badge UUID to use for the receipts endpoints.
  63. Picks the first badge with a non-empty `available_sites` list (the one
  64. actually tied to a real site), falling back to the first badge returned.
  65. Returns (badge_uuid, site_label) for logging.
  66. """
  67. r = session.get(f"{toqla_auth.BASE_URL}/api/2.0/users/badges", timeout=30)
  68. r.raise_for_status()
  69. badges = _list_from(r.json(), "items", "badges", "data", "results")
  70. if not badges:
  71. log.error("No badges returned — check your token.")
  72. sys.exit(1)
  73. chosen = next((b for b in badges if b.get("available_sites")), badges[0])
  74. checkout_system = chosen.get("checkout_system") or {}
  75. badge_uuid = checkout_system.get("id")
  76. if not badge_uuid:
  77. log.error("Could not find a badge UUID in the badges response.")
  78. sys.exit(1)
  79. sites = [s.get("name") for s in (chosen.get("available_sites") or [])]
  80. label = ", ".join(filter(None, sites)) or checkout_system.get("display_name") or badge_uuid
  81. return badge_uuid, label
  82. def fetch_receipts(session: requests.Session, badge_uuid: str, days: int) -> list[dict]:
  83. """Return the receipts list for the last `days` days, sorted ascending by date."""
  84. end = datetime.now(LOCAL_UTC_OFFSET)
  85. start = end - timedelta(days=days)
  86. params = {
  87. "start-date": _iso_with_colon_offset(start),
  88. "end-date": _iso_with_colon_offset(end),
  89. }
  90. r = session.get(
  91. f"{toqla_auth.BASE_URL}/api/2.0/users/badges/{badge_uuid}/receipts",
  92. params=params, timeout=30,
  93. )
  94. r.raise_for_status()
  95. receipts = _list_from(r.json(), "items", "receipts", "data", "results")
  96. receipts.sort(key=lambda t: t.get("date") or "")
  97. return receipts
  98. def fetch_receipt_detail(session: requests.Session, badge_uuid: str, ticket_id: str) -> str:
  99. """Fetch a single receipt and return its content as clean plain text."""
  100. r = session.get(
  101. f"{toqla_auth.BASE_URL}/api/2.0/users/badges/{badge_uuid}/receipts/{ticket_id}",
  102. timeout=30,
  103. )
  104. r.raise_for_status()
  105. body = r.json() or {}
  106. return _html_to_text(body.get("html") or "")
  107. def _iso_with_colon_offset(dt: datetime) -> str:
  108. """Format a datetime as e.g. 2026-08-01T00:00:00+02:00 (colon in the offset)."""
  109. s = dt.strftime("%Y-%m-%dT%H:%M:%S%z")
  110. return s[:-2] + ":" + s[-2:]
  111. # ---------------------------------------------------------------------------
  112. # HTML → plain text
  113. # ---------------------------------------------------------------------------
  114. _TAG_RE = re.compile(r"<[^>]+>")
  115. def _html_to_text(raw_html: str) -> str:
  116. """Turn the receipt's inline-styled HTML into readable plain text."""
  117. if not raw_html:
  118. return ""
  119. text = re.sub(r"(?i)<br\s*/?>", "\n", raw_html)
  120. text = re.sub(r"(?i)</pre\s*>", "\n", text)
  121. text = _TAG_RE.sub("", text)
  122. text = html.unescape(text)
  123. lines = [line.rstrip() for line in text.splitlines()]
  124. while lines and not lines[0].strip():
  125. lines.pop(0)
  126. while lines and not lines[-1].strip():
  127. lines.pop()
  128. return "\n".join(lines)
  129. # ---------------------------------------------------------------------------
  130. # Last-processed reference
  131. # ---------------------------------------------------------------------------
  132. def load_last_ticket(path: Path) -> dict | None:
  133. if not path.exists():
  134. return None
  135. try:
  136. data = json.loads(path.read_text(encoding="utf-8"))
  137. if isinstance(data, dict) and data.get("last_date"):
  138. return data
  139. except (json.JSONDecodeError, OSError):
  140. pass
  141. return None
  142. def save_last_ticket(path: Path, ticket: dict) -> None:
  143. path.write_text(
  144. json.dumps({"last_date": ticket["date"], "last_id": ticket["id"]}, indent=2),
  145. encoding="utf-8",
  146. )
  147. # ---------------------------------------------------------------------------
  148. # Telegram
  149. # ---------------------------------------------------------------------------
  150. def build_message(ticket: dict, text: str) -> str:
  151. kind = "🛒 Achat" if ticket.get("type") == "Debit" else "💳 Rechargement"
  152. amount = ticket.get("amount")
  153. amount_str = f"{amount:.2f} €" if isinstance(amount, (int, float)) else str(amount)
  154. date_str = ticket.get("date", "")
  155. header = f"{kind} — {date_str} — {amount_str}"
  156. escaped = html.escape(text) if text else "(contenu indisponible)"
  157. return f"{html.escape(header)}\n<pre>{escaped}</pre>"
  158. def _chunk_message(message: str, limit: int = TELEGRAM_MAX_LEN) -> list[str]:
  159. if len(message) <= limit:
  160. return [message]
  161. chunks = []
  162. while message:
  163. chunks.append(message[:limit])
  164. message = message[limit:]
  165. return chunks
  166. def send_telegram_message(message: str) -> None:
  167. if "<PUT_TELEGRAM" in TELEGRAM_BOT_TOKEN or "<PUT_TELEGRAM" in TELEGRAM_CHAT_ID:
  168. log.error(
  169. "TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID are not configured. "
  170. "Set them at the top of fetch_tickets.py or via the "
  171. "TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID environment variables."
  172. )
  173. sys.exit(1)
  174. url = TELEGRAM_API_URL.format(token=TELEGRAM_BOT_TOKEN)
  175. for chunk in _chunk_message(message):
  176. for attempt in range(3):
  177. try:
  178. r = requests.post(
  179. url,
  180. data={
  181. "chat_id": TELEGRAM_CHAT_ID,
  182. "text": chunk,
  183. "parse_mode": "HTML",
  184. },
  185. timeout=30,
  186. )
  187. r.raise_for_status()
  188. break
  189. except requests.RequestException as exc:
  190. log.warning("Telegram send failed (attempt %d/3): %s", attempt + 1, exc)
  191. if attempt < 2:
  192. time.sleep(2 ** attempt)
  193. else:
  194. raise
  195. # ---------------------------------------------------------------------------
  196. # CLI
  197. # ---------------------------------------------------------------------------
  198. def _parse_args() -> argparse.Namespace:
  199. p = argparse.ArgumentParser(
  200. description="Fetch new Toqla/Sodexo tickets and send them to Telegram.",
  201. )
  202. p.add_argument("--cookies", "-c", metavar="COOKIE_STR",
  203. help='Auth0 session cookies, e.g. "did=...; auth0=..."')
  204. p.add_argument("--cookies-file", metavar="FILE",
  205. help=f"File containing cookies (default: {toqla_auth.COOKIES_FILE})")
  206. p.add_argument("--dry-run", action="store_true",
  207. help="Print the messages instead of sending them to Telegram, "
  208. "and don't update the last-ticket reference.")
  209. return p.parse_args()
  210. def run() -> None:
  211. args = _parse_args()
  212. last_ticket_path = Path(LAST_TICKET_FILE)
  213. if not args.dry_run and ("<PUT_TELEGRAM" in TELEGRAM_BOT_TOKEN or "<PUT_TELEGRAM" in TELEGRAM_CHAT_ID):
  214. log.error(
  215. "TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID are not configured. "
  216. "Set them at the top of fetch_tickets.py or via the "
  217. "TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID environment variables."
  218. )
  219. sys.exit(1)
  220. cookies_raw = args.cookies or os.environ.get("COOKIES", "")
  221. if args.cookies_file:
  222. try:
  223. cookies_raw = Path(args.cookies_file).read_text(encoding="utf-8").strip()
  224. except FileNotFoundError:
  225. log.error("Cookies file not found: %s", args.cookies_file)
  226. sys.exit(1)
  227. cookies = toqla_auth.parse_cookie_string(cookies_raw) if cookies_raw else {}
  228. token = toqla_auth.refresh_access_token(cookies if cookies else None)
  229. session = toqla_auth.build_session(token)
  230. badge_uuid, site_label = fetch_badge_uuid(session)
  231. log.info("Using badge %s (%s)", badge_uuid, site_label)
  232. receipts = fetch_receipts(session, badge_uuid, LOOKBACK_DAYS)
  233. log.info("Found %d receipt(s) in the last %d day(s).", len(receipts), LOOKBACK_DAYS)
  234. last_ticket = load_last_ticket(last_ticket_path)
  235. if last_ticket is None:
  236. log.info("No stored reference — processing only the most recent ticket.")
  237. candidates = [receipts[-1]] if receipts else []
  238. else:
  239. candidates = [r for r in receipts if (r.get("date") or "") > last_ticket["last_date"]]
  240. if not candidates:
  241. log.info("Nothing new to send.")
  242. return
  243. log.info("%d new ticket(s) to send.", len(candidates))
  244. for ticket in candidates:
  245. ticket_id = ticket.get("id")
  246. text = fetch_receipt_detail(session, badge_uuid, ticket_id)
  247. message = build_message(ticket, text)
  248. if args.dry_run:
  249. log.info("[dry-run] Would send ticket %s:\n%s", ticket_id, message)
  250. else:
  251. send_telegram_message(message)
  252. log.info("Sent ticket %s (%s, %s).", ticket_id, ticket.get("type"), ticket.get("date"))
  253. save_last_ticket(last_ticket_path, ticket)
  254. time.sleep(0.3)
  255. if __name__ == "__main__":
  256. run()