for assorted things
at main 19 kB view raw
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()