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"] 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 pydantic import BaseModel, Field 132from pydantic_settings import BaseSettings, SettingsConfigDict 133 134 135class Settings(BaseSettings): 136 """App settings loaded from environment variables""" 137 138 model_config = SettingsConfigDict(env_file=".env", extra="ignore") 139 140 bsky_handle: str = Field(...) 141 bsky_password: str = Field(...) 142 flightradar_api_token: str = Field(...) 143 144 145class Subscriber(BaseModel): 146 """Subscriber with location information""" 147 148 handle: str 149 latitude: float 150 longitude: float 151 radius_miles: float = 5.0 152 153 154class Flight(BaseModel): 155 """Flight data model""" 156 157 hex: str 158 latitude: float 159 longitude: float 160 altitude: float | None = None 161 ground_speed: float | None = None 162 heading: float | None = None 163 aircraft_type: str | None = None 164 registration: str | None = None 165 origin: str | None = None 166 destination: str | None = None 167 callsign: str | None = None 168 distance_miles: float 169 170 171def get_flights_in_area( 172 settings: Settings, latitude: float, longitude: float, radius_miles: float 173) -> list[Flight]: 174 """Get flights within the specified radius using FlightRadar24 API.""" 175 lat_offset = radius_miles / 69 # 1 degree latitude ≈ 69 miles 176 lon_offset = radius_miles / (69 * abs(math.cos(math.radians(latitude)))) 177 178 bounds = { 179 "north": latitude + lat_offset, 180 "south": latitude - lat_offset, 181 "west": longitude - lon_offset, 182 "east": longitude + lon_offset, 183 } 184 185 headers = { 186 "Authorization": f"Bearer {settings.flightradar_api_token}", 187 "Accept": "application/json", 188 "Accept-Version": "v1", 189 } 190 191 url = "https://fr24api.flightradar24.com/api/live/flight-positions/full" 192 params = { 193 "bounds": f"{bounds['north']},{bounds['south']},{bounds['west']},{bounds['east']}" 194 } 195 196 try: 197 with httpx.Client() as client: 198 response = client.get(url, headers=headers, params=params, timeout=10) 199 response.raise_for_status() 200 data = response.json() 201 202 flights_in_radius = [] 203 center = (latitude, longitude) 204 205 if isinstance(data, dict) and "data" in data: 206 for flight_data in data["data"]: 207 lat = flight_data.get("lat") 208 lon = flight_data.get("lon") 209 210 if lat and lon: 211 flight_pos = (lat, lon) 212 dist = distance.distance(center, flight_pos).miles 213 if dist <= radius_miles: 214 flight = Flight( 215 hex=flight_data.get("fr24_id", ""), 216 latitude=lat, 217 longitude=lon, 218 altitude=flight_data.get("alt"), 219 ground_speed=flight_data.get("gspeed"), 220 heading=flight_data.get("track"), 221 aircraft_type=flight_data.get("type"), 222 registration=flight_data.get("reg"), 223 origin=flight_data.get("orig_iata"), 224 destination=flight_data.get("dest_iata"), 225 callsign=flight_data.get("flight"), 226 distance_miles=round(dist, 2), 227 ) 228 flights_in_radius.append(flight) 229 230 return flights_in_radius 231 except httpx.HTTPStatusError as e: 232 print(f"HTTP error fetching flights: {e}") 233 print(f"Response status: {e.response.status_code}") 234 print(f"Response content: {e.response.text[:500]}") 235 return [] 236 except Exception as e: 237 print(f"Error fetching flights: {e}") 238 return [] 239 240 241def format_flight_info(flight: Flight) -> str: 242 """Format flight information for a DM.""" 243 parts = ["✈️ Flight passing overhead!\n"] 244 245 parts.append(f"Flight: {flight.callsign or 'Unknown'}") 246 parts.append(f"Distance: {flight.distance_miles} miles") 247 248 if flight.altitude: 249 parts.append(f"Altitude: {flight.altitude:,.0f} ft") 250 if flight.ground_speed: 251 parts.append(f"Speed: {flight.ground_speed:.0f} kts") 252 if flight.heading: 253 parts.append(f"Heading: {flight.heading:.0f}°") 254 if flight.aircraft_type: 255 parts.append(f"Aircraft: {flight.aircraft_type}") 256 257 if flight.origin or flight.destination: 258 route = f"{flight.origin or '???'} → {flight.destination or '???'}" 259 parts.append(f"Route: {route}") 260 261 parts.append(f"\nTime: {datetime.now().strftime('%H:%M:%S')}") 262 263 return "\n".join(parts) 264 265 266def send_dm(client: Client, message: str, target_handle: str) -> bool: 267 """Send a direct message to the specified handle on BlueSky.""" 268 try: 269 resolved = client.com.atproto.identity.resolve_handle( 270 params={"handle": target_handle} 271 ) 272 target_did = resolved.did 273 274 chat_client = client.with_bsky_chat_proxy() 275 276 convo_response = chat_client.chat.bsky.convo.get_convo_for_members( 277 {"members": [target_did]} 278 ) 279 280 if not convo_response or not convo_response.convo: 281 print(f"Could not create/get conversation with {target_handle}") 282 return False 283 284 recipient = None 285 for member in convo_response.convo.members: 286 if member.did != client.me.did: 287 recipient = member 288 break 289 290 if not recipient or recipient.handle != target_handle: 291 print( 292 f"ERROR: About to message wrong person! Expected {target_handle}, but found {recipient.handle if recipient else 'no recipient'}" 293 ) 294 return False 295 296 chat_client.chat.bsky.convo.send_message( 297 data={ 298 "convoId": convo_response.convo.id, 299 "message": {"text": message, "facets": []}, 300 } 301 ) 302 303 print(f"DM sent to {target_handle}") 304 return True 305 306 except Exception as e: 307 print(f"Error sending DM to {target_handle}: {e}") 308 return False 309 310 311def process_subscriber( 312 client: Client, 313 settings: Settings, 314 subscriber: Subscriber, 315 notified_flights: dict[str, set[str]], 316) -> None: 317 """Process flights for a single subscriber.""" 318 try: 319 flights = get_flights_in_area( 320 settings, subscriber.latitude, subscriber.longitude, subscriber.radius_miles 321 ) 322 323 if subscriber.handle not in notified_flights: 324 notified_flights[subscriber.handle] = set() 325 326 subscriber_notified = notified_flights[subscriber.handle] 327 328 for flight in flights: 329 flight_id = flight.hex 330 331 if flight_id not in subscriber_notified: 332 message = format_flight_info(flight) 333 print(f"\n[{subscriber.handle}] {message}\n") 334 335 if send_dm(client, message, subscriber.handle): 336 print(f"DM sent to {subscriber.handle} for flight {flight_id}") 337 subscriber_notified.add(flight_id) 338 else: 339 print( 340 f"Failed to send DM to {subscriber.handle} for flight {flight_id}" 341 ) 342 343 current_flight_ids = {f.hex for f in flights} 344 notified_flights[subscriber.handle] &= current_flight_ids 345 346 if not flights: 347 print( 348 f"[{subscriber.handle}] No flights in range at {datetime.now().strftime('%H:%M:%S')}" 349 ) 350 351 except Exception as e: 352 print(f"Error processing subscriber {subscriber.handle}: {e}") 353 354 355def load_subscribers(subscribers_input: str | None) -> list[Subscriber]: 356 """Load subscribers from JSON file or stdin.""" 357 if subscribers_input: 358 with open(subscribers_input, "r") as f: 359 data = json.load(f) 360 else: 361 print("Reading subscriber data from stdin (provide JSON array)...") 362 data = json.load(sys.stdin) 363 364 return [Subscriber(**item) for item in data] 365 366 367def main(): 368 """Main monitoring loop.""" 369 parser = argparse.ArgumentParser( 370 description="Monitor flights overhead and send BlueSky DMs" 371 ) 372 373 parser.add_argument( 374 "--subscribers", 375 type=str, 376 help="JSON file with subscriber list, or '-' for stdin", 377 ) 378 parser.add_argument( 379 "--latitude", type=float, default=41.8781, help="Latitude (default: Chicago)" 380 ) 381 parser.add_argument( 382 "--longitude", type=float, default=-87.6298, help="Longitude (default: Chicago)" 383 ) 384 parser.add_argument( 385 "--radius", type=float, default=5.0, help="Radius in miles (default: 5)" 386 ) 387 parser.add_argument( 388 "--handle", 389 type=str, 390 default="alternatebuild.dev", 391 help="BlueSky handle to DM (default: alternatebuild.dev)", 392 ) 393 parser.add_argument( 394 "--interval", 395 type=int, 396 default=60, 397 help="Check interval in seconds (default: 60)", 398 ) 399 parser.add_argument( 400 "--once", action="store_true", help="Run once and exit (for testing)" 401 ) 402 parser.add_argument( 403 "--max-workers", 404 type=int, 405 default=5, 406 help="Max concurrent workers for processing subscribers (default: 5)", 407 ) 408 args = parser.parse_args() 409 410 try: 411 settings = Settings() 412 except Exception as e: 413 print(f"Error loading settings: {e}") 414 print( 415 "Ensure .env file exists with BSKY_HANDLE, BSKY_PASSWORD, and FLIGHTRADAR_API_TOKEN" 416 ) 417 return 418 419 client = Client() 420 try: 421 client.login(settings.bsky_handle, settings.bsky_password) 422 print(f"Logged in to BlueSky as {settings.bsky_handle}") 423 except Exception as e: 424 print(f"Error logging into BlueSky: {e}") 425 return 426 427 if args.subscribers: 428 if args.subscribers == "-": 429 subscribers_input = None 430 else: 431 subscribers_input = args.subscribers 432 433 try: 434 subscribers = load_subscribers(subscribers_input) 435 print(f"Loaded {len(subscribers)} subscriber(s)") 436 except Exception as e: 437 print(f"Error loading subscribers: {e}") 438 return 439 else: 440 subscribers = [ 441 Subscriber( 442 handle=args.handle, 443 latitude=args.latitude, 444 longitude=args.longitude, 445 radius_miles=args.radius, 446 ) 447 ] 448 print( 449 f"Monitoring flights within {args.radius} miles of ({args.latitude}, {args.longitude}) for {args.handle}" 450 ) 451 452 print(f"Checking every {args.interval} seconds...") 453 454 notified_flights: dict[str, set[str]] = {} 455 456 while True: 457 try: 458 with ThreadPoolExecutor(max_workers=args.max_workers) as executor: 459 futures = [] 460 for subscriber in subscribers: 461 future = executor.submit( 462 process_subscriber, 463 client, 464 settings, 465 subscriber, 466 notified_flights, 467 ) 468 futures.append(future) 469 470 for future in as_completed(futures): 471 future.result() 472 473 if args.once: 474 break 475 476 time.sleep(args.interval) 477 478 except KeyboardInterrupt: 479 print("\nStopping flight monitor...") 480 break 481 except Exception as e: 482 print(f"Error in monitoring loop: {e}") 483 time.sleep(args.interval) 484 485 486if __name__ == "__main__": 487 main()