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()