vinted-bot 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. vinted_bot/__init__.py +3 -0
  2. vinted_bot/__main__.py +3 -0
  3. vinted_bot/app.py +400 -0
  4. vinted_bot/botctl/__init__.py +1 -0
  5. vinted_bot/botctl/telegram_bot.py +582 -0
  6. vinted_bot/cli.py +490 -0
  7. vinted_bot/config.py +288 -0
  8. vinted_bot/db/__init__.py +6 -0
  9. vinted_bot/db/connection.py +126 -0
  10. vinted_bot/db/export.py +98 -0
  11. vinted_bot/db/migrations/0001_init.sql +124 -0
  12. vinted_bot/db/migrations/0002_seller_details.sql +6 -0
  13. vinted_bot/db/migrations/0003_listing_gallery.sql +7 -0
  14. vinted_bot/db/migrations/0004_richer_searches.sql +277 -0
  15. vinted_bot/db/migrations/0005_resale_and_mute.sql +17 -0
  16. vinted_bot/db/migrations/0006_dedup_that_survives.sql +66 -0
  17. vinted_bot/db/migrations.py +100 -0
  18. vinted_bot/db/repo.py +2145 -0
  19. vinted_bot/deals.py +85 -0
  20. vinted_bot/deliver/__init__.py +15 -0
  21. vinted_bot/deliver/apprise.py +136 -0
  22. vinted_bot/deliver/base.py +88 -0
  23. vinted_bot/deliver/discord.py +819 -0
  24. vinted_bot/deliver/dispatcher.py +359 -0
  25. vinted_bot/deliver/labels.py +452 -0
  26. vinted_bot/deliver/ntfy.py +208 -0
  27. vinted_bot/deliver/preview.py +218 -0
  28. vinted_bot/deliver/ratelimit.py +80 -0
  29. vinted_bot/deliver/render.py +390 -0
  30. vinted_bot/deliver/telegram.py +551 -0
  31. vinted_bot/deliver/webhook.py +186 -0
  32. vinted_bot/engine/__init__.py +6 -0
  33. vinted_bot/engine/dedup.py +135 -0
  34. vinted_bot/engine/filters.py +249 -0
  35. vinted_bot/engine/health.py +204 -0
  36. vinted_bot/engine/poller.py +484 -0
  37. vinted_bot/engine/quiet.py +37 -0
  38. vinted_bot/engine/self_update.py +621 -0
  39. vinted_bot/engine/updates.py +81 -0
  40. vinted_bot/engine/validation.py +293 -0
  41. vinted_bot/engine/watchdog.py +142 -0
  42. vinted_bot/engine/watcher.py +215 -0
  43. vinted_bot/log.py +52 -0
  44. vinted_bot/vinted/__init__.py +34 -0
  45. vinted_bot/vinted/agents.json +78 -0
  46. vinted_bot/vinted/canary.py +100 -0
  47. vinted_bot/vinted/client.py +420 -0
  48. vinted_bot/vinted/errors.py +73 -0
  49. vinted_bot/vinted/headers.py +239 -0
  50. vinted_bot/vinted/item_page.py +228 -0
  51. vinted_bot/vinted/models.py +373 -0
  52. vinted_bot/vinted/proxies.py +94 -0
  53. vinted_bot/vinted/session.py +289 -0
  54. vinted_bot/vinted/taxonomy.py +343 -0
  55. vinted_bot/vinted/transport.py +382 -0
  56. vinted_bot/vinted/urls.py +329 -0
  57. vinted_bot/web/__init__.py +1 -0
  58. vinted_bot/web/core.py +245 -0
  59. vinted_bot/web/data.py +156 -0
  60. vinted_bot/web/destinations.py +207 -0
  61. vinted_bot/web/forms.py +366 -0
  62. vinted_bot/web/preview.py +97 -0
  63. vinted_bot/web/routes/__init__.py +1 -0
  64. vinted_bot/web/routes/alerts.py +208 -0
  65. vinted_bot/web/routes/api.py +293 -0
  66. vinted_bot/web/routes/auth.py +85 -0
  67. vinted_bot/web/routes/finds.py +308 -0
  68. vinted_bot/web/routes/searches.py +471 -0
  69. vinted_bot/web/routes/settings_page.py +185 -0
  70. vinted_bot/web/routes/stats.py +157 -0
  71. vinted_bot/web/routes/updating.py +37 -0
  72. vinted_bot/web/routes/watchlist.py +147 -0
  73. vinted_bot/web/routes/welcome.py +394 -0
  74. vinted_bot/web/security.py +236 -0
  75. vinted_bot/web/server.py +308 -0
  76. vinted_bot/web/static/LICENSE-lucide.txt +15 -0
  77. vinted_bot/web/static/css/app.css +1211 -0
  78. vinted_bot/web/static/css/tokens.css +276 -0
  79. vinted_bot/web/static/fonts/Figtree-latin-ext.woff2 +0 -0
  80. vinted_bot/web/static/fonts/Figtree-latin.woff2 +0 -0
  81. vinted_bot/web/static/fonts/Gabarito-latin-ext.woff2 +0 -0
  82. vinted_bot/web/static/fonts/Gabarito-latin.woff2 +0 -0
  83. vinted_bot/web/static/fonts/OFL-Figtree.txt +93 -0
  84. vinted_bot/web/static/fonts/OFL-Gabarito.txt +93 -0
  85. vinted_bot/web/static/img/demo/corduroy-trousers.webp +0 -0
  86. vinted_bot/web/static/img/demo/denim-jacket.webp +0 -0
  87. vinted_bot/web/static/img/demo/straight-leg-jeans.webp +0 -0
  88. vinted_bot/web/static/img/demo/wool-coat.webp +0 -0
  89. vinted_bot/web/static/img/icons/apple-touch-icon.png +0 -0
  90. vinted_bot/web/static/img/icons/favicon.ico +0 -0
  91. vinted_bot/web/static/img/icons/favicon.svg +13 -0
  92. vinted_bot/web/static/img/icons/icon-192.png +0 -0
  93. vinted_bot/web/static/img/icons/icon-512.png +0 -0
  94. vinted_bot/web/static/img/icons/icon-maskable-512.png +0 -0
  95. vinted_bot/web/static/img/mags/mags-alert.webp +0 -0
  96. vinted_bot/web/static/img/mags/mags-cheer.webp +0 -0
  97. vinted_bot/web/static/img/mags/mags-find.webp +0 -0
  98. vinted_bot/web/static/img/mags/mags-head.webp +0 -0
  99. vinted_bot/web/static/img/mags/mags-parcel.webp +0 -0
  100. vinted_bot/web/static/img/mags/mags-point.webp +0 -0
  101. vinted_bot/web/static/img/mags/mags-sleep.webp +0 -0
  102. vinted_bot/web/static/img/mags/mags-think.webp +0 -0
  103. vinted_bot/web/static/img/mags/mags-watch.webp +0 -0
  104. vinted_bot/web/static/img/mags/mags-wave.webp +0 -0
  105. vinted_bot/web/static/img/mags/mags-worried.webp +0 -0
  106. vinted_bot/web/static/js/app.js +976 -0
  107. vinted_bot/web/static/js/qrcode.js +2297 -0
  108. vinted_bot/web/support.py +73 -0
  109. vinted_bot/web/templates/alerts.html +131 -0
  110. vinted_bot/web/templates/bare.html +14 -0
  111. vinted_bot/web/templates/base.html +110 -0
  112. vinted_bot/web/templates/error.html +12 -0
  113. vinted_bot/web/templates/index.html +184 -0
  114. vinted_bot/web/templates/login.html +32 -0
  115. vinted_bot/web/templates/ob_base.html +54 -0
  116. vinted_bot/web/templates/partials/banners.html +57 -0
  117. vinted_bot/web/templates/partials/card.html +1 -0
  118. vinted_bot/web/templates/partials/dest_forms.html +109 -0
  119. vinted_bot/web/templates/partials/head.html +14 -0
  120. vinted_bot/web/templates/partials/icons.svg +109 -0
  121. vinted_bot/web/templates/partials/macros.html +87 -0
  122. vinted_bot/web/templates/partials/ob_back.html +1 -0
  123. vinted_bot/web/templates/partials/support.html +94 -0
  124. vinted_bot/web/templates/search-detail.html +103 -0
  125. vinted_bot/web/templates/search-edit.html +319 -0
  126. vinted_bot/web/templates/searches.html +158 -0
  127. vinted_bot/web/templates/settings.html +183 -0
  128. vinted_bot/web/templates/stats.html +118 -0
  129. vinted_bot/web/templates/watchlist.html +80 -0
  130. vinted_bot/web/templates/welcome-1.html +36 -0
  131. vinted_bot/web/templates/welcome-2.html +48 -0
  132. vinted_bot/web/templates/welcome-3.html +85 -0
  133. vinted_bot/web/templates/welcome-4.html +51 -0
  134. vinted_bot/web/templates/welcome-5.html +48 -0
  135. vinted_bot/web/templates/welcome-6.html +37 -0
  136. vinted_bot/web/views.py +615 -0
  137. vinted_bot-2.1.0.dist-info/METADATA +282 -0
  138. vinted_bot-2.1.0.dist-info/RECORD +141 -0
  139. vinted_bot-2.1.0.dist-info/WHEEL +4 -0
  140. vinted_bot-2.1.0.dist-info/entry_points.txt +2 -0
  141. vinted_bot-2.1.0.dist-info/licenses/LICENSE +21 -0
vinted_bot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Watch Vinted searches and get notified when something matches."""
2
+
3
+ __version__ = "2.1.0"
vinted_bot/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from vinted_bot.cli import main
2
+
3
+ raise SystemExit(main())
vinted_bot/app.py ADDED
@@ -0,0 +1,400 @@
1
+ """Starting everything up and shutting it down cleanly.
2
+
3
+ One process, one event loop, one SQLite file. A task per search, a dispatcher, a watchdog,
4
+ a heartbeat, and optionally the web UI and the Telegram bot. A supervisor keeps the running
5
+ searches in step with what is in the database, so adding one in the web UI takes effect
6
+ without a restart.
7
+
8
+ Shutdown matters more than it sounds. Docker sends SIGTERM and waits about ten seconds
9
+ before killing the process; a loop sitting in a sixty-second sleep will be killed mid-send
10
+ every single time. So nothing here sleeps — everything waits on a stop event with a
11
+ timeout, which comes back the instant a signal arrives.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import contextlib
18
+ import random
19
+ import signal
20
+ import sqlite3
21
+ import webbrowser
22
+
23
+ import httpx
24
+
25
+ from vinted_bot.config import MAX_BACKOFF_S, Settings
26
+ from vinted_bot.db import Database, apply_pending
27
+ from vinted_bot.db.repo import Query, Repo
28
+ from vinted_bot.deliver.dispatcher import Dispatcher
29
+ from vinted_bot.engine import self_update, updates
30
+ from vinted_bot.engine.health import Heartbeat
31
+ from vinted_bot.engine.poller import Poller
32
+ from vinted_bot.engine.watchdog import Watchdog
33
+ from vinted_bot.engine.watcher import Watcher
34
+ from vinted_bot.log import get_logger
35
+ from vinted_bot.vinted.client import VintedClient
36
+ from vinted_bot.vinted.proxies import ProxyRotation
37
+ from vinted_bot.vinted.session import SessionManager
38
+ from vinted_bot.vinted.taxonomy import Taxonomy
39
+ from vinted_bot.vinted.transport import Transport, TransportPool, build_transport
40
+
41
+ log = get_logger(__name__)
42
+
43
+ # How often to notice searches added, paused or edited elsewhere.
44
+ SUPERVISE_INTERVAL_S = 15.0
45
+
46
+ # Housekeeping: prune old listings and the rejections log, check for a newer release.
47
+ HOUSEKEEPING_INTERVAL_S = 3600.0
48
+
49
+ # Searches started in the same pass — all of them, after a restart — begin this far apart
50
+ # (plus jitter), so they do not hit the site in the same second.
51
+ STARTUP_SPACING_S = 2.0
52
+
53
+ # How long `run --open` waits for the dashboard to start listening.
54
+ _OPEN_BROWSER_WAIT_S = 20.0
55
+
56
+
57
+ class Application:
58
+ """The whole running system."""
59
+
60
+ def __init__(
61
+ self,
62
+ settings: Settings,
63
+ *,
64
+ supervise_interval_s: float = SUPERVISE_INTERVAL_S,
65
+ open_browser: bool = False,
66
+ install: self_update.Install | None = None,
67
+ ) -> None:
68
+ self._settings = settings
69
+ # How this copy was installed; worked out at startup unless a test says.
70
+ self._install = install
71
+ self._supervise_interval_s = supervise_interval_s
72
+ self._open_browser = open_browser
73
+ self._stop = asyncio.Event()
74
+ self._work_available = asyncio.Event()
75
+ self._rng = random.Random()
76
+ self._poller_tasks: dict[int, asyncio.Task[None]] = {}
77
+ # The search as its poller was started with it. Any edit makes it unequal, and
78
+ # the poller is restarted with the new rules on the next pass.
79
+ self._poller_signatures: dict[int, Query] = {}
80
+ # Crashes in a row per search, for spacing out restarts. A crash after at least
81
+ # one good check starts the count again.
82
+ self._crashes: dict[int, int] = {}
83
+
84
+ def request_stop(self) -> None:
85
+ self._stop.set()
86
+
87
+ async def run(self) -> int:
88
+ """Run until stopped. The exit code: RESTART_EXIT_CODE after an update."""
89
+ settings = self._settings
90
+
91
+ async with Database(settings.db_path) as db:
92
+ await apply_pending(db)
93
+ repo = Repo(db)
94
+
95
+ proxies = ProxyRotation.from_file(settings.proxy_file)
96
+ mock_dir = settings.mock_scenario_dir if settings.fetch_mode == "mock" else None
97
+
98
+ def build(proxy: str | None) -> Transport:
99
+ return build_transport(
100
+ impersonate=settings.http_impersonate,
101
+ timeout=settings.request_timeout_s,
102
+ proxy=proxy,
103
+ mock_dir=mock_dir,
104
+ )
105
+
106
+ pool = TransportPool(build)
107
+ # For the health check ping and the daily update check, never for Vinted.
108
+ http = httpx.AsyncClient(timeout=10.0)
109
+ try:
110
+ sessions = SessionManager(
111
+ db,
112
+ pool,
113
+ rotate_after_minutes=settings.session_rotate_minutes,
114
+ proxies=proxies,
115
+ )
116
+ client = VintedClient(None, sessions, keep_raw=settings.keep_raw_json)
117
+ dispatcher = Dispatcher(
118
+ repo=repo,
119
+ settings=settings,
120
+ stop=self._stop,
121
+ work_available=self._work_available,
122
+ )
123
+ watchdog = Watchdog(
124
+ repo=repo,
125
+ sessions=sessions,
126
+ settings=settings,
127
+ stop=self._stop,
128
+ announce=dispatcher.notify_status,
129
+ )
130
+ heartbeat = Heartbeat(repo, self._stop, http=http)
131
+ updater = self_update.Updater(settings, repo, self._stop, self._install)
132
+ watcher = Watcher(
133
+ repo=repo,
134
+ stop=self._stop,
135
+ work_available=self._work_available,
136
+ item_snapshot=client.item_snapshot,
137
+ seller_items=client.seller_items,
138
+ )
139
+
140
+ self._install_signal_handlers()
141
+ log.info(
142
+ "app.starting",
143
+ db=str(settings.db_path),
144
+ fetch_mode=settings.fetch_mode,
145
+ web=settings.web_enabled,
146
+ install=updater.install.kind.value,
147
+ supervisor=settings.supervised,
148
+ )
149
+
150
+ try:
151
+ async with asyncio.TaskGroup() as tg:
152
+ tg.create_task(dispatcher.run(), name="dispatcher")
153
+ tg.create_task(watchdog.run(), name="watchdog")
154
+ tg.create_task(heartbeat.run(), name="heartbeat")
155
+ tg.create_task(watcher.run(), name="watcher")
156
+ tg.create_task(self._housekeeping(repo, http), name="housekeeping")
157
+ tg.create_task(updater.run_automatically(), name="auto-update")
158
+ tg.create_task(self._report_update(repo, dispatcher), name="update-report")
159
+ tg.create_task(
160
+ self._supervise(tg, repo, client, sessions), name="supervisor"
161
+ )
162
+
163
+ if settings.telegram_bot_token is not None:
164
+ tg.create_task(self._run_telegram_bot(repo), name="telegram-bot")
165
+ if settings.web_enabled:
166
+ taxonomy = Taxonomy(sessions, repo)
167
+ tg.create_task(
168
+ self._run_web(
169
+ repo, taxonomy, client, watcher.available_kinds, updater
170
+ ),
171
+ name="web",
172
+ )
173
+ if self._open_browser:
174
+ tg.create_task(self._open_dashboard(), name="open-browser")
175
+ except* Exception as group:
176
+ for error in group.exceptions:
177
+ log.exception("app.task_failed", error=str(error))
178
+ raise
179
+ # A shutdown halfway through an upgrade would leave it half installed.
180
+ await updater.settle()
181
+ finally:
182
+ await http.aclose()
183
+ await pool.aclose()
184
+
185
+ if updater.restart_requested:
186
+ log.info("app.stopped", restarting_for="update")
187
+ return self_update.RESTART_EXIT_CODE
188
+ log.info("app.stopped")
189
+ return 0
190
+
191
+ # --- Supervision ---------------------------------------------------------------
192
+
193
+ async def _supervise(
194
+ self,
195
+ tg: asyncio.TaskGroup,
196
+ repo: Repo,
197
+ client: VintedClient,
198
+ sessions: SessionManager,
199
+ ) -> None:
200
+ """Keep one running task per active search."""
201
+ while not self._stop.is_set():
202
+ try:
203
+ await self._reconcile(tg, repo, client, sessions)
204
+ except Exception as exc:
205
+ log.exception("supervisor.failed", error=str(exc))
206
+
207
+ with contextlib.suppress(TimeoutError):
208
+ await asyncio.wait_for(self._stop.wait(), timeout=self._supervise_interval_s)
209
+
210
+ for task in self._poller_tasks.values():
211
+ task.cancel()
212
+
213
+ async def _reconcile(
214
+ self,
215
+ tg: asyncio.TaskGroup,
216
+ repo: Repo,
217
+ client: VintedClient,
218
+ sessions: SessionManager,
219
+ ) -> None:
220
+ queries = {query.id: query for query in await repo.list_queries(include_paused=False)}
221
+
222
+ for query_id in list(self._poller_tasks):
223
+ query = queries.get(query_id)
224
+ task = self._poller_tasks[query_id]
225
+ changed = query is not None and query != self._poller_signatures.get(query_id)
226
+ if query is None or changed or task.done():
227
+ task.cancel()
228
+ del self._poller_tasks[query_id]
229
+ self._poller_signatures.pop(query_id, None)
230
+ if query is None or changed:
231
+ # An edit is a reason to try again straight away.
232
+ self._crashes.pop(query_id, None)
233
+ if query is None:
234
+ log.info("poller.stopped", query_id=query_id)
235
+
236
+ starting = [
237
+ query for query_id, query in queries.items() if query_id not in self._poller_tasks
238
+ ]
239
+ for position, query in enumerate(starting):
240
+ query_id = query.id
241
+ # The first starts at once, so a newly added search checks straight away.
242
+ delay = position * STARTUP_SPACING_S
243
+ if position:
244
+ delay += self._rng.uniform(0, STARTUP_SPACING_S)
245
+ delay = min(delay, float(query.poll_interval_s))
246
+ if crashes := self._crashes.get(query_id, 0):
247
+ delay = self._restart_delay(query, crashes)
248
+ poller = Poller(
249
+ query,
250
+ repo=repo,
251
+ client=client,
252
+ sessions=sessions,
253
+ settings=self._settings,
254
+ stop=self._stop,
255
+ work_available=self._work_available,
256
+ seller_lookup=client.seller,
257
+ start_delay_s=delay,
258
+ )
259
+ self._poller_tasks[query_id] = tg.create_task(
260
+ self._guard(poller), name=f"poll:{query_id}"
261
+ )
262
+ self._poller_signatures[query_id] = query
263
+ log.info(
264
+ "poller.started",
265
+ query_id=query_id,
266
+ query=query.name,
267
+ tld=query.tld,
268
+ sites=query.sites,
269
+ every_s=query.poll_interval_s,
270
+ start_in_s=round(delay, 1),
271
+ )
272
+
273
+ def _restart_delay(self, query: Query, crashes: int) -> float:
274
+ """How long a crashed search waits before its next start: the backoff ladder.
275
+
276
+ Restarting at once would turn a search that crashes on every check into one that
277
+ asks Vinted for something every supervisor pass, forever.
278
+ """
279
+ step = min(2.0 ** (crashes - 1) * query.poll_interval_s, MAX_BACKOFF_S)
280
+ return step + self._rng.uniform(0, step * 0.15)
281
+
282
+ async def _guard(self, poller: Poller) -> None:
283
+ """Run a search's loop so that its failure cannot take the others down with it."""
284
+ query_id = poller.query.id
285
+ try:
286
+ await poller.run()
287
+ except asyncio.CancelledError:
288
+ raise
289
+ except Exception as exc:
290
+ crashes = 1 if poller.successes else self._crashes.get(query_id, 0) + 1
291
+ self._crashes[query_id] = crashes
292
+ log.exception(
293
+ "poller.crashed",
294
+ query_id=query_id,
295
+ error=str(exc),
296
+ crashes_in_a_row=crashes,
297
+ )
298
+
299
+ # --- Background chores ---------------------------------------------------------
300
+
301
+ async def _housekeeping(self, repo: Repo, http: httpx.AsyncClient) -> None:
302
+ while not self._stop.is_set():
303
+ try:
304
+ pruned = await repo.prune_items(self._settings.item_retention_days)
305
+ rejections = await repo.prune_rejections()
306
+ if pruned or rejections:
307
+ log.debug("housekeeping.pruned", items=pruned, rejections=rejections)
308
+ # Offline mode is offline: no call to GitHub either.
309
+ if self._settings.fetch_mode == "live":
310
+ await updates.check_for_update(repo, http)
311
+ except sqlite3.OperationalError as exc:
312
+ # A locked database; the next pass an hour from now will catch up.
313
+ log.warning("housekeeping.db_unavailable", error=str(exc))
314
+ with contextlib.suppress(TimeoutError):
315
+ await asyncio.wait_for(self._stop.wait(), timeout=HOUSEKEEPING_INTERVAL_S)
316
+
317
+ async def _report_update(self, repo: Repo, dispatcher: Dispatcher) -> None:
318
+ """Say how the update that restarted the app went (self_update.finish_pending)."""
319
+ try:
320
+ await self_update.finish_pending(repo, dispatcher.notify_status)
321
+ except sqlite3.OperationalError as exc:
322
+ # A locked database: the note stays, and the next start reports it instead.
323
+ log.warning("update.report_failed", error=str(exc))
324
+
325
+ async def _run_telegram_bot(self, repo: Repo) -> None:
326
+ from vinted_bot.botctl.telegram_bot import run_bot # noqa: PLC0415
327
+
328
+ token = self._settings.telegram_bot_token
329
+ if token is None: # pragma: no cover - guarded by the caller
330
+ return
331
+ try:
332
+ await run_bot(token.get_secret_value(), repo=repo, stop=self._stop)
333
+ except Exception as exc:
334
+ log.exception("telegram_bot.failed", error=str(exc))
335
+
336
+ async def _run_web(
337
+ self,
338
+ repo: Repo,
339
+ taxonomy: Taxonomy,
340
+ client: VintedClient,
341
+ watch_kinds: frozenset[str],
342
+ updater: self_update.Updater,
343
+ ) -> None:
344
+ # Imported here so CLI commands that never serve the dashboard skip loading FastAPI.
345
+ from vinted_bot.web.server import serve # noqa: PLC0415
346
+
347
+ try:
348
+ await serve(
349
+ self._settings,
350
+ repo,
351
+ self._stop,
352
+ taxonomy,
353
+ client=client,
354
+ watch_kinds=watch_kinds,
355
+ updater=updater,
356
+ )
357
+ except Exception as exc:
358
+ log.exception("web.failed", error=str(exc))
359
+
360
+ async def _open_dashboard(self) -> None:
361
+ """Open the dashboard in a browser once it is listening (`run --open`)."""
362
+ url = self._settings.dashboard_url
363
+ if url is None: # pragma: no cover - only scheduled while the web UI is on
364
+ return
365
+ host = (
366
+ "127.0.0.1" if self._settings.web_host in {"0.0.0.0", "::"} else self._settings.web_host
367
+ )
368
+ deadline = asyncio.get_running_loop().time() + _OPEN_BROWSER_WAIT_S
369
+ while not self._stop.is_set() and asyncio.get_running_loop().time() < deadline:
370
+ try:
371
+ _, writer = await asyncio.wait_for(
372
+ asyncio.open_connection(host, self._settings.web_port), timeout=1.0
373
+ )
374
+ except (OSError, TimeoutError):
375
+ with contextlib.suppress(TimeoutError):
376
+ await asyncio.wait_for(self._stop.wait(), timeout=0.25)
377
+ continue
378
+ writer.close()
379
+ await writer.wait_closed()
380
+ # webbrowser can block while it launches a process, so it gets a thread.
381
+ await asyncio.to_thread(webbrowser.open, url)
382
+ log.info("web.opened", url=url)
383
+ return
384
+ log.warning("web.open_timed_out", url=url)
385
+
386
+ # --- Signals -------------------------------------------------------------------
387
+
388
+ def _install_signal_handlers(self) -> None:
389
+ loop = asyncio.get_running_loop()
390
+ for sig in (signal.SIGTERM, signal.SIGINT):
391
+ with contextlib.suppress(NotImplementedError):
392
+ loop.add_signal_handler(sig, self._on_signal, sig)
393
+
394
+ def _on_signal(self, sig: signal.Signals) -> None:
395
+ log.info("app.signal", signal=sig.name)
396
+ self._stop.set()
397
+
398
+
399
+ async def run(settings: Settings, *, open_browser: bool = False) -> int:
400
+ return await Application(settings, open_browser=open_browser).run()
@@ -0,0 +1 @@
1
+ """The Telegram bot: chat pairing and status replies."""