for assorted things
1#!/usr/bin/env -S uv run --script --quiet
2# /// script
3# requires-python = ">=3.12"
4# dependencies = ["atproto", "pydantic-settings", "geopy", "httpx", "jinja2"]
5# ///
6"""
7Monitor flights passing overhead and send BlueSky DMs.
8
9Usage:
10 # Single user mode (backward compatible)
11 ./dm-me-when-a-flight-passes-over
12
13 # Multi-subscriber mode with JSON file
14 ./dm-me-when-a-flight-passes-over --subscribers subscribers.json
15
16 # Multi-subscriber mode with stdin
17 echo '[{"handle": "user1.bsky.social", "latitude": 41.8781, "longitude": -87.6298, "radius_miles": 5}]' | ./dm-me-when-a-flight-passes-over --subscribers -
18
19This script monitors flights within a configurable radius and sends DMs on BlueSky
20when flights pass overhead. Supports multiple subscribers with different locations.
21
22## Future Architecture Ideas
23
24### Web App Deployment Options
25
261. **FastAPI + Fly.io/Railway/Render**
27 - REST API with endpoints:
28 - POST /subscribe - Register user with BlueSky handle
29 - DELETE /unsubscribe - Remove subscription
30 - POST /update-location - Update user's location
31 - GET /status - Check subscription status
32 - Background worker using Celery/RQ/APScheduler
33 - PostgreSQL/SQLite for subscriber persistence
34 - Redis for caching flight data & deduplication
35
362. **Vercel/Netlify Edge Functions**
37 - Serverless approach with scheduled cron jobs
38 - Use Vercel KV or Upstash Redis for state
39 - Challenge: Long-running monitoring needs workarounds
40 - Solution: Trigger checks via cron every minute
41
423. **Self-Hosted with ngrok/Cloudflare Tunnel**
43 - Quick prototype option
44 - Run this script as daemon
45 - Expose simple Flask/FastAPI wrapper
46 - Security concerns: rate limiting, auth required
47
48### Mobile/Browser Integration
49
501. **Progressive Web App (PWA)**
51 - Service worker for background location updates
52 - Geolocation API for current position
53 - Push notifications instead of/alongside DMs
54 - IndexedDB for offline capability
55
562. **iOS Shortcuts Integration**
57 - Create shortcut that gets location
58 - Calls webhook with location + BlueSky handle
59 - Could run automatically based on focus modes
60
613. **Browser Extension**
62 - Background script polls location
63 - Lighter weight than full app
64 - Cross-platform solution
65
66### Architecture Components
67
681. **Location Services Layer**
69 - Browser Geolocation API
70 - IP-based geolocation fallback
71 - Manual location picker UI
72 - Privacy: Only send location when checking flights
73
742. **Notification Options**
75 - BlueSky DMs (current)
76 - Web Push Notifications
77 - Webhooks to other services
78 - Email/SMS via Twilio/SendGrid
79
803. **Subscription Management**
81 - OAuth with BlueSky for auth
82 - User preferences: radius, notification types
83 - Quiet hours/Do Not Disturb
84 - Rate limiting per user
85
864. **Data Optimization**
87 - Cache FlightRadar API responses
88 - Batch location updates
89 - Aggregate nearby users for efficiency
90 - WebSocket for real-time updates
91
92### Implementation Approach
93
94Phase 1: Web API Wrapper
95- FastAPI with /subscribe endpoint
96- SQLite for subscribers
97- Run monitoring in background thread
98- Deploy to Fly.io free tier
99
100Phase 2: Web UI
101- Simple React/Vue form
102- Geolocation permission request
103- Show nearby flights on map
104- Subscription management
105
106Phase 3: Mobile Experience
107- PWA with service workers
108- Background location updates
109- Local notifications
110- Offline support
111
112### Security Considerations
113- Rate limit FlightRadar API calls
114- Authenticate BlueSky handles
115- Validate location bounds
116- Prevent subscription spam
117- GDPR compliance for location data
118"""
119
120import argparse
121import time
122import math
123import json
124import sys
125from datetime import datetime
126from concurrent.futures import ThreadPoolExecutor, as_completed
127
128import httpx
129from atproto import Client
130from geopy import distance
131from jinja2 import Template
132from pydantic import BaseModel, Field
133from pydantic_settings import BaseSettings, SettingsConfigDict
134
135
136class Settings(BaseSettings):
137 """App settings loaded from environment variables"""
138
139 model_config = SettingsConfigDict(env_file=".env", extra="ignore")
140
141 bsky_handle: str = Field(...)
142 bsky_password: str = Field(...)
143 flightradar_api_token: str = Field(...)
144
145
146class Subscriber(BaseModel):
147 """Subscriber with location and notification preferences"""
148
149 handle: str
150 latitude: float
151 longitude: float
152 radius_miles: float = 5.0
153 filters: dict[str, list[str]] = Field(default_factory=dict)
154 message_template: str | None = None
155
156
157class Flight(BaseModel):
158 """Flight data model"""
159
160 hex: str
161 latitude: float
162 longitude: float
163 altitude: float | None = None
164 ground_speed: float | None = None
165 heading: float | None = None
166 aircraft_type: str | None = None
167 registration: str | None = None
168 origin: str | None = None
169 destination: str | None = None
170 callsign: str | None = None
171 distance_miles: float
172
173
174def get_flights_in_area(
175 settings: Settings, latitude: float, longitude: float, radius_miles: float
176) -> list[Flight]:
177 """Get flights within the specified radius using FlightRadar24 API."""
178 lat_offset = radius_miles / 69 # 1 degree latitude ≈ 69 miles
179 lon_offset = radius_miles / (69 * abs(math.cos(math.radians(latitude))))
180
181 bounds = {
182 "north": latitude + lat_offset,
183 "south": latitude - lat_offset,
184 "west": longitude - lon_offset,
185 "east": longitude + lon_offset,
186 }
187
188 headers = {
189 "Authorization": f"Bearer {settings.flightradar_api_token}",
190 "Accept": "application/json",
191 "Accept-Version": "v1",
192 }
193
194 url = "https://fr24api.flightradar24.com/api/live/flight-positions/full"
195 params = {
196 "bounds": f"{bounds['north']},{bounds['south']},{bounds['west']},{bounds['east']}"
197 }
198
199 try:
200 with httpx.Client() as client:
201 response = client.get(url, headers=headers, params=params, timeout=10)
202 response.raise_for_status()
203 data = response.json()
204
205 flights_in_radius = []
206 center = (latitude, longitude)
207
208 if isinstance(data, dict) and "data" in data:
209 for flight_data in data["data"]:
210 lat = flight_data.get("lat")
211 lon = flight_data.get("lon")
212
213 if lat and lon:
214 flight_pos = (lat, lon)
215 dist = distance.distance(center, flight_pos).miles
216 if dist <= radius_miles:
217 flight = Flight(
218 hex=flight_data.get("fr24_id", ""),
219 latitude=lat,
220 longitude=lon,
221 altitude=flight_data.get("alt"),
222 ground_speed=flight_data.get("gspeed"),
223 heading=flight_data.get("track"),
224 aircraft_type=flight_data.get("type"),
225 registration=flight_data.get("reg"),
226 origin=flight_data.get("orig_iata"),
227 destination=flight_data.get("dest_iata"),
228 callsign=flight_data.get("flight"),
229 distance_miles=round(dist, 2),
230 )
231 flights_in_radius.append(flight)
232
233 return flights_in_radius
234 except httpx.HTTPStatusError as e:
235 print(f"HTTP error fetching flights: {e}")
236 print(f"Response status: {e.response.status_code}")
237 print(f"Response content: {e.response.text[:500]}")
238 return []
239 except Exception as e:
240 print(f"Error fetching flights: {e}")
241 return []
242
243
244DEFAULT_MESSAGE_TEMPLATE = """✈️ Flight passing overhead!
245
246Flight: {{ flight.callsign or 'Unknown' }}
247Distance: {{ flight.distance_miles }} miles
248{%- if flight.altitude %}
249Altitude: {{ "{:,.0f}".format(flight.altitude) }} ft
250{%- endif %}
251{%- if flight.ground_speed %}
252Speed: {{ "{:.0f}".format(flight.ground_speed) }} kts
253{%- endif %}
254{%- if flight.heading %}
255Heading: {{ "{:.0f}".format(flight.heading) }}°
256{%- endif %}
257{%- if flight.aircraft_type %}
258Aircraft: {{ flight.aircraft_type }}
259{%- endif %}
260{%- if flight.origin or flight.destination %}
261Route: {{ flight.origin or '???' }} → {{ flight.destination or '???' }}
262{%- endif %}
263
264Time: {{ timestamp }}"""
265
266
267def format_flight_info(flight: Flight, template_str: str | None = None) -> str:
268 """Format flight information for a DM using Jinja2 template."""
269 template_str = template_str or DEFAULT_MESSAGE_TEMPLATE
270 template = Template(template_str)
271
272 return template.render(
273 flight=flight,
274 timestamp=datetime.now().strftime('%H:%M:%S')
275 )
276
277
278def send_dm(client: Client, message: str, target_handle: str) -> bool:
279 """Send a direct message to the specified handle on BlueSky."""
280 try:
281 resolved = client.com.atproto.identity.resolve_handle(
282 params={"handle": target_handle}
283 )
284 target_did = resolved.did
285
286 chat_client = client.with_bsky_chat_proxy()
287
288 convo_response = chat_client.chat.bsky.convo.get_convo_for_members(
289 {"members": [target_did]}
290 )
291
292 if not convo_response or not convo_response.convo:
293 print(f"Could not create/get conversation with {target_handle}")
294 return False
295
296 recipient = None
297 for member in convo_response.convo.members:
298 if member.did != client.me.did:
299 recipient = member
300 break
301
302 if not recipient or recipient.handle != target_handle:
303 print(
304 f"ERROR: About to message wrong person! Expected {target_handle}, but found {recipient.handle if recipient else 'no recipient'}"
305 )
306 return False
307
308 chat_client.chat.bsky.convo.send_message(
309 data={
310 "convoId": convo_response.convo.id,
311 "message": {"text": message, "facets": []},
312 }
313 )
314
315 print(f"DM sent to {target_handle}")
316 return True
317
318 except Exception as e:
319 print(f"Error sending DM to {target_handle}: {e}")
320 return False
321
322
323def flight_matches_filters(flight: Flight, filters: dict[str, list[str]]) -> bool:
324 """Check if a flight matches the subscriber's filters."""
325 if not filters:
326 return True
327
328 for field, allowed_values in filters.items():
329 if not allowed_values:
330 continue
331
332 flight_value = getattr(flight, field, None)
333 if flight_value is None:
334 return False
335
336 if field == "aircraft_type":
337 # Case-insensitive partial matching for aircraft types
338 flight_value_lower = str(flight_value).lower()
339 if not any(allowed.lower() in flight_value_lower for allowed in allowed_values):
340 return False
341 else:
342 # Exact matching for other fields
343 if str(flight_value) not in [str(v) for v in allowed_values]:
344 return False
345
346 return True
347
348
349def process_subscriber(
350 client: Client,
351 settings: Settings,
352 subscriber: Subscriber,
353 notified_flights: dict[str, set[str]],
354) -> None:
355 """Process flights for a single subscriber."""
356 try:
357 flights = get_flights_in_area(
358 settings, subscriber.latitude, subscriber.longitude, subscriber.radius_miles
359 )
360
361 if subscriber.handle not in notified_flights:
362 notified_flights[subscriber.handle] = set()
363
364 subscriber_notified = notified_flights[subscriber.handle]
365 filtered_count = 0
366
367 for flight in flights:
368 flight_id = flight.hex
369
370 if not flight_matches_filters(flight, subscriber.filters):
371 filtered_count += 1
372 continue
373
374 if flight_id not in subscriber_notified:
375 message = format_flight_info(flight, subscriber.message_template)
376 print(f"\n[{subscriber.handle}] {message}\n")
377
378 if send_dm(client, message, subscriber.handle):
379 print(f"DM sent to {subscriber.handle} for flight {flight_id}")
380 subscriber_notified.add(flight_id)
381 else:
382 print(
383 f"Failed to send DM to {subscriber.handle} for flight {flight_id}"
384 )
385
386 current_flight_ids = {f.hex for f in flights}
387 notified_flights[subscriber.handle] &= current_flight_ids
388
389 if not flights:
390 print(
391 f"[{subscriber.handle}] No flights in range at {datetime.now().strftime('%H:%M:%S')}"
392 )
393 elif filtered_count > 0 and filtered_count == len(flights):
394 print(
395 f"[{subscriber.handle}] {filtered_count} flights filtered out at {datetime.now().strftime('%H:%M:%S')}"
396 )
397
398 except Exception as e:
399 print(f"Error processing subscriber {subscriber.handle}: {e}")
400
401
402def load_subscribers(subscribers_input: str | None) -> list[Subscriber]:
403 """Load subscribers from JSON file or stdin."""
404 if subscribers_input:
405 with open(subscribers_input, "r") as f:
406 data = json.load(f)
407 else:
408 print("Reading subscriber data from stdin (provide JSON array)...")
409 data = json.load(sys.stdin)
410
411 return [Subscriber(**item) for item in data]
412
413
414def main():
415 """Main monitoring loop."""
416 parser = argparse.ArgumentParser(
417 description="Monitor flights overhead and send BlueSky DMs"
418 )
419
420 parser.add_argument(
421 "--subscribers",
422 type=str,
423 help="JSON file with subscriber list, or '-' for stdin",
424 )
425 parser.add_argument(
426 "--latitude", type=float, default=41.8781, help="Latitude (default: Chicago)"
427 )
428 parser.add_argument(
429 "--longitude", type=float, default=-87.6298, help="Longitude (default: Chicago)"
430 )
431 parser.add_argument(
432 "--radius", type=float, default=5.0, help="Radius in miles (default: 5)"
433 )
434 parser.add_argument(
435 "--handle",
436 type=str,
437 default="alternatebuild.dev",
438 help="BlueSky handle to DM (default: alternatebuild.dev)",
439 )
440 parser.add_argument(
441 "--filter-aircraft-type",
442 type=str,
443 nargs="+",
444 help="Filter by aircraft types (e.g., B737 A320 C172)",
445 )
446 parser.add_argument(
447 "--filter-callsign",
448 type=str,
449 nargs="+",
450 help="Filter by callsigns (e.g., UAL DL AAL)",
451 )
452 parser.add_argument(
453 "--filter-origin",
454 type=str,
455 nargs="+",
456 help="Filter by origin airports (e.g., ORD LAX JFK)",
457 )
458 parser.add_argument(
459 "--filter-destination",
460 type=str,
461 nargs="+",
462 help="Filter by destination airports (e.g., ORD LAX JFK)",
463 )
464 parser.add_argument(
465 "--message-template",
466 type=str,
467 help="Custom Jinja2 template for messages",
468 )
469 parser.add_argument(
470 "--message-template-file",
471 type=str,
472 help="Path to file containing custom Jinja2 template",
473 )
474 parser.add_argument(
475 "--interval",
476 type=int,
477 default=60,
478 help="Check interval in seconds (default: 60)",
479 )
480 parser.add_argument(
481 "--once", action="store_true", help="Run once and exit (for testing)"
482 )
483 parser.add_argument(
484 "--max-workers",
485 type=int,
486 default=5,
487 help="Max concurrent workers for processing subscribers (default: 5)",
488 )
489 args = parser.parse_args()
490
491 try:
492 settings = Settings()
493 except Exception as e:
494 print(f"Error loading settings: {e}")
495 print(
496 "Ensure .env file exists with BSKY_HANDLE, BSKY_PASSWORD, and FLIGHTRADAR_API_TOKEN"
497 )
498 return
499
500 client = Client()
501 try:
502 client.login(settings.bsky_handle, settings.bsky_password)
503 print(f"Logged in to BlueSky as {settings.bsky_handle}")
504 except Exception as e:
505 print(f"Error logging into BlueSky: {e}")
506 return
507
508 if args.subscribers:
509 if args.subscribers == "-":
510 subscribers_input = None
511 else:
512 subscribers_input = args.subscribers
513
514 try:
515 subscribers = load_subscribers(subscribers_input)
516 print(f"Loaded {len(subscribers)} subscriber(s)")
517 except Exception as e:
518 print(f"Error loading subscribers: {e}")
519 return
520 else:
521 # Build filters from CLI args
522 filters = {}
523 if args.filter_aircraft_type:
524 filters["aircraft_type"] = args.filter_aircraft_type
525 if args.filter_callsign:
526 filters["callsign"] = args.filter_callsign
527 if args.filter_origin:
528 filters["origin"] = args.filter_origin
529 if args.filter_destination:
530 filters["destination"] = args.filter_destination
531
532 # Load custom template if provided
533 message_template = None
534 if args.message_template_file:
535 with open(args.message_template_file, "r") as f:
536 message_template = f.read()
537 elif args.message_template:
538 message_template = args.message_template
539
540 subscribers = [
541 Subscriber(
542 handle=args.handle,
543 latitude=args.latitude,
544 longitude=args.longitude,
545 radius_miles=args.radius,
546 filters=filters,
547 message_template=message_template,
548 )
549 ]
550 print(
551 f"Monitoring flights within {args.radius} miles of ({args.latitude}, {args.longitude}) for {args.handle}"
552 )
553 if filters:
554 print(f"Active filters: {filters}")
555
556 print(f"Checking every {args.interval} seconds...")
557
558 notified_flights: dict[str, set[str]] = {}
559
560 while True:
561 try:
562 with ThreadPoolExecutor(max_workers=args.max_workers) as executor:
563 futures = []
564 for subscriber in subscribers:
565 future = executor.submit(
566 process_subscriber,
567 client,
568 settings,
569 subscriber,
570 notified_flights,
571 )
572 futures.append(future)
573
574 for future in as_completed(futures):
575 future.result()
576
577 if args.once:
578 break
579
580 time.sleep(args.interval)
581
582 except KeyboardInterrupt:
583 print("\nStopping flight monitor...")
584 break
585 except Exception as e:
586 print(f"Error in monitoring loop: {e}")
587 time.sleep(args.interval)
588
589
590if __name__ == "__main__":
591 main()