diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index dc518843e4..4dd9cd25d9 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -252,6 +252,7 @@ def cleanup_document_cache(max_age_hours: int = 24) -> int: class MessageType(Enum): """Types of incoming messages.""" TEXT = "text" + LOCATION = "location" PHOTO = "photo" VIDEO = "video" AUDIO = "audio" diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index c49155d0a9..4371bfdbde 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -132,6 +132,10 @@ class TelegramAdapter(BasePlatformAdapter): filters.COMMAND, self._handle_command )) + self._app.add_handler(TelegramMessageHandler( + filters.LOCATION | getattr(filters, "VENUE", filters.LOCATION), + self._handle_location_message + )) self._app.add_handler(TelegramMessageHandler( filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL, self._handle_media_message @@ -546,6 +550,41 @@ class TelegramAdapter(BasePlatformAdapter): event = self._build_message_event(update.message, MessageType.COMMAND) await self.handle_message(event) + async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle incoming location/venue pin messages.""" + if not update.message: + return + + msg = update.message + venue = getattr(msg, "venue", None) + location = getattr(venue, "location", None) if venue else getattr(msg, "location", None) + + if not location: + return + + lat = getattr(location, "latitude", None) + lon = getattr(location, "longitude", None) + if lat is None or lon is None: + return + + # Build a text message with coordinates and context + parts = ["[The user shared a location pin.]"] + if venue: + title = getattr(venue, "title", None) + address = getattr(venue, "address", None) + if title: + parts.append(f"Venue: {title}") + if address: + parts.append(f"Address: {address}") + parts.append(f"latitude: {lat}") + parts.append(f"longitude: {lon}") + parts.append(f"Map: https://www.google.com/maps/search/?api=1&query={lat},{lon}") + parts.append("Ask what they'd like to find nearby (restaurants, cafes, etc.) and any preferences.") + + event = self._build_message_event(msg, MessageType.LOCATION) + event.text = "\n".join(parts) + await self.handle_message(event) + async def _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming media messages, downloading images to local cache.""" if not update.message: diff --git a/skills/leisure/find-nearby/SKILL.md b/skills/leisure/find-nearby/SKILL.md new file mode 100644 index 0000000000..f0ecdbf531 --- /dev/null +++ b/skills/leisure/find-nearby/SKILL.md @@ -0,0 +1,69 @@ +--- +name: find-nearby +description: Find nearby places (restaurants, cafes, bars, pharmacies, etc.) using OpenStreetMap. Works with coordinates, addresses, cities, zip codes, or Telegram location pins. No API keys needed. +version: 1.0.0 +metadata: + hermes: + tags: [location, maps, nearby, places, restaurants, local] + related_skills: [] +--- + +# Find Nearby — Local Place Discovery + +Find restaurants, cafes, bars, pharmacies, and other places near any location. Uses OpenStreetMap (free, no API keys). Works with: + +- **Coordinates** from Telegram location pins (latitude/longitude in conversation) +- **Addresses** ("near 123 Main St, Springfield") +- **Cities** ("restaurants in downtown Austin") +- **Zip codes** ("pharmacies near 90210") +- **Landmarks** ("cafes near Times Square") + +## Quick Reference + +```bash +# By coordinates (from Telegram location pin or user-provided) +python3 SKILL_DIR/scripts/find_nearby.py --lat --lon --type restaurant --radius 1500 + +# By address, city, or landmark (auto-geocoded) +python3 SKILL_DIR/scripts/find_nearby.py --near "Times Square, New York" --type cafe + +# Multiple place types +python3 SKILL_DIR/scripts/find_nearby.py --near "downtown austin" --type restaurant --type bar --limit 10 + +# JSON output +python3 SKILL_DIR/scripts/find_nearby.py --near "90210" --type pharmacy --json +``` + +### Parameters + +| Flag | Description | Default | +|------|-------------|---------| +| `--lat`, `--lon` | Exact coordinates | — | +| `--near` | Address, city, zip, or landmark (geocoded) | — | +| `--type` | Place type (repeatable for multiple) | restaurant | +| `--radius` | Search radius in meters | 1500 | +| `--limit` | Max results | 15 | +| `--json` | Machine-readable JSON output | off | + +### Common Place Types + +`restaurant`, `cafe`, `bar`, `pub`, `fast_food`, `pharmacy`, `hospital`, `bank`, `atm`, `fuel`, `parking`, `supermarket`, `convenience`, `hotel` + +## Workflow + +1. **Get the location.** Look for coordinates (`latitude: ... / longitude: ...`) from a Telegram pin, or ask the user for an address/city/zip. + +2. **Ask for preferences** (only if not already stated): place type, how far they're willing to go, any specifics (cuisine, "open now", etc.). + +3. **Run the script** with appropriate flags. Use `--json` if you need to process results programmatically. + +4. **Present results** with names, distances, and Google Maps links. If the user asked about hours or "open now," check the `hours` field in results — if missing or unclear, verify with `web_search`. + +5. **For directions**, use the `directions_url` from results, or construct: `https://www.google.com/maps/dir/?api=1&origin=,&destination=,` + +## Tips + +- If results are sparse, widen the radius (1500 → 3000m) +- For "open now" requests: check the `hours` field in results, cross-reference with `web_search` for accuracy since OSM hours aren't always complete +- Zip codes alone can be ambiguous globally — prompt the user for country/state if results look wrong +- The script uses OpenStreetMap data which is community-maintained; coverage varies by region diff --git a/skills/leisure/find-nearby/scripts/find_nearby.py b/skills/leisure/find-nearby/scripts/find_nearby.py new file mode 100644 index 0000000000..543d35a0dd --- /dev/null +++ b/skills/leisure/find-nearby/scripts/find_nearby.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Find nearby places using OpenStreetMap (Overpass + Nominatim). No API keys needed. + +Usage: + # By coordinates + python find_nearby.py --lat 36.17 --lon -115.14 --type restaurant --radius 1500 + + # By address/city/zip (auto-geocoded) + python find_nearby.py --near "Times Square, New York" --type cafe --radius 1000 + python find_nearby.py --near "90210" --type pharmacy + + # Multiple types + python find_nearby.py --lat 36.17 --lon -115.14 --type restaurant --type bar + + # JSON output for programmatic use + python find_nearby.py --near "downtown las vegas" --type restaurant --json +""" + +import argparse +import json +import math +import sys +import urllib.parse +import urllib.request +from typing import Any + +OVERPASS_URLS = [ + "https://overpass-api.de/api/interpreter", + "https://overpass.kumi.systems/api/interpreter", +] +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" +USER_AGENT = "HermesAgent/1.0 (find-nearby skill)" +TIMEOUT = 15 + + +def _http_get(url: str) -> Any: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + return json.loads(r.read()) + + +def _http_post(url: str, data: str) -> Any: + req = urllib.request.Request( + url, data=data.encode(), headers={"User-Agent": USER_AGENT} + ) + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + return json.loads(r.read()) + + +def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Distance in meters between two coordinates.""" + R = 6_371_000 + rlat1, rlat2 = math.radians(lat1), math.radians(lat2) + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2 + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def geocode(query: str) -> tuple[float, float]: + """Convert address/city/zip to coordinates via Nominatim.""" + params = urllib.parse.urlencode({"q": query, "format": "json", "limit": 1}) + results = _http_get(f"{NOMINATIM_URL}?{params}") + if not results: + print(f"Error: Could not geocode '{query}'. Try a more specific address.", file=sys.stderr) + sys.exit(1) + return float(results[0]["lat"]), float(results[0]["lon"]) + + +def find_nearby(lat: float, lon: float, types: list[str], radius: int = 1500, limit: int = 15) -> list[dict]: + """Query Overpass for nearby amenities.""" + # Build Overpass QL query + type_filters = "".join( + f'nwr["amenity"="{t}"](around:{radius},{lat},{lon});' for t in types + ) + query = f"[out:json][timeout:{TIMEOUT}];({type_filters});out center tags;" + + # Try each Overpass server + data = None + for url in OVERPASS_URLS: + try: + data = _http_post(url, f"data={urllib.parse.quote(query)}") + break + except Exception: + continue + + if not data: + return [] + + # Parse results + places = [] + for el in data.get("elements", []): + tags = el.get("tags", {}) + name = tags.get("name") + if not name: + continue + + # Get coordinates (nodes have lat/lon directly, ways/relations use center) + plat = el.get("lat") or (el.get("center", {}) or {}).get("lat") + plon = el.get("lon") or (el.get("center", {}) or {}).get("lon") + if not plat or not plon: + continue + + dist = haversine(lat, lon, plat, plon) + + place = { + "name": name, + "type": tags.get("amenity", ""), + "distance_m": round(dist), + "lat": plat, + "lon": plon, + "maps_url": f"https://www.google.com/maps/search/?api=1&query={plat},{plon}", + "directions_url": f"https://www.google.com/maps/dir/?api=1&origin={lat},{lon}&destination={plat},{plon}", + } + + # Add useful optional fields + if tags.get("cuisine"): + place["cuisine"] = tags["cuisine"] + if tags.get("opening_hours"): + place["hours"] = tags["opening_hours"] + if tags.get("phone"): + place["phone"] = tags["phone"] + if tags.get("website"): + place["website"] = tags["website"] + if tags.get("addr:street"): + addr_parts = [tags.get("addr:housenumber", ""), tags.get("addr:street", "")] + if tags.get("addr:city"): + addr_parts.append(tags["addr:city"]) + place["address"] = " ".join(p for p in addr_parts if p) + + places.append(place) + + # Sort by distance, limit results + places.sort(key=lambda p: p["distance_m"]) + return places[:limit] + + +def main(): + parser = argparse.ArgumentParser(description="Find nearby places via OpenStreetMap") + parser.add_argument("--lat", type=float, help="Latitude") + parser.add_argument("--lon", type=float, help="Longitude") + parser.add_argument("--near", type=str, help="Address, city, or zip code (geocoded automatically)") + parser.add_argument("--type", action="append", dest="types", default=[], help="Place type (restaurant, cafe, bar, pharmacy, etc.)") + parser.add_argument("--radius", type=int, default=1500, help="Search radius in meters (default: 1500)") + parser.add_argument("--limit", type=int, default=15, help="Max results (default: 15)") + parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON") + args = parser.parse_args() + + # Resolve coordinates + if args.near: + lat, lon = geocode(args.near) + elif args.lat is not None and args.lon is not None: + lat, lon = args.lat, args.lon + else: + print("Error: Provide --lat/--lon or --near", file=sys.stderr) + sys.exit(1) + + if not args.types: + args.types = ["restaurant"] + + places = find_nearby(lat, lon, args.types, args.radius, args.limit) + + if args.json_output: + print(json.dumps({"origin": {"lat": lat, "lon": lon}, "results": places, "count": len(places)}, indent=2)) + else: + if not places: + print(f"No {'/'.join(args.types)} found within {args.radius}m") + return + print(f"Found {len(places)} places within {args.radius}m:\n") + for i, p in enumerate(places, 1): + dist_str = f"{p['distance_m']}m" if p["distance_m"] < 1000 else f"{p['distance_m']/1000:.1f}km" + print(f" {i}. {p['name']} ({p['type']}) — {dist_str}") + if p.get("cuisine"): + print(f" Cuisine: {p['cuisine']}") + if p.get("hours"): + print(f" Hours: {p['hours']}") + if p.get("address"): + print(f" Address: {p['address']}") + print(f" Map: {p['maps_url']}") + print() + + +if __name__ == "__main__": + main()