-
Notifications
You must be signed in to change notification settings - Fork 2k
Add Hacker News Omi integration app #7412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| web: uvicorn main:app --host 0.0.0.0 --port ${PORT:-8080} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # Hacker News Omi Integration | ||
|
|
||
| Read Hacker News from Omi with chat tools. This app does not require user auth. | ||
|
|
||
| ## Tools | ||
|
|
||
| - `get_front_page`: returns current front page stories. | ||
| - `search_stories`: searches Hacker News stories by keyword, sorted by relevance or date. | ||
| - `get_discussion`: fetches a story/item plus top-level comments. | ||
|
|
||
| ## Local Development | ||
|
|
||
| ```bash | ||
| python -m venv .venv | ||
| source .venv/bin/activate | ||
| pip install -r requirements.txt | ||
| uvicorn main:app --reload --port 8080 | ||
| ``` | ||
|
|
||
| Open `http://localhost:8080/.well-known/omi-tools.json` to inspect the Omi tools manifest. | ||
|
|
||
| ## Deployment | ||
|
|
||
| Deploy this folder as a standalone FastAPI service. No environment variables are required. | ||
|
|
||
| ## Example Requests | ||
|
|
||
| ```bash | ||
| curl -X POST http://localhost:8080/tools/get_front_page \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{"limit": 5}' | ||
| ``` | ||
|
|
||
| ```bash | ||
| curl -X POST http://localhost:8080/tools/search_stories \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{"query": "open source", "sort_by": "date", "limit": 5}' | ||
| ``` | ||
|
|
||
| ```bash | ||
| curl -X POST http://localhost:8080/tools/get_discussion \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{"item_id": 8863, "comment_limit": 3}' | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,262 @@ | ||
| """ | ||
| Hacker News Integration App for Omi. | ||
|
|
||
| Provides chat tools for reading the Hacker News front page, searching stories, | ||
| and fetching an item with top-level comments. | ||
| """ | ||
|
|
||
| from html import unescape | ||
| import re | ||
| from typing import Any, Optional | ||
|
|
||
| import httpx | ||
| from fastapi import FastAPI | ||
| from fastapi.responses import HTMLResponse | ||
| from pydantic import BaseModel | ||
|
|
||
|
|
||
| ALGOLIA_BASE_URL = "https://hn.algolia.com/api/v1" | ||
| REQUEST_TIMEOUT_SECONDS = 10 | ||
| MAX_LIMIT = 20 | ||
|
|
||
|
|
||
| app = FastAPI( | ||
| title="Omi Hacker News Integration", | ||
| description="Read and search Hacker News from Omi chat tools", | ||
| version="1.0.0", | ||
| ) | ||
|
|
||
|
|
||
| class ChatToolResponse(BaseModel): | ||
| """Response model for Omi chat tool endpoints.""" | ||
|
|
||
| result: Optional[str] = None | ||
| error: Optional[str] = None | ||
|
|
||
|
|
||
| def _clean_text(value: Optional[str]) -> str: | ||
| """Clean basic HTML entities/tags commonly returned by the HN API.""" | ||
| if not value: | ||
| return "" | ||
|
|
||
| text = unescape(value) | ||
| text = re.sub(r"</?(p|pre|blockquote|ul|ol|li)[^>]*>", "\n", text, flags=re.IGNORECASE) | ||
| text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE) | ||
| text = re.sub(r"<code[^>]*>", "`", text, flags=re.IGNORECASE) | ||
| text = re.sub(r"</code>", "`", text, flags=re.IGNORECASE) | ||
| text = re.sub(r"<[^>]+>", "", text) | ||
| text = re.sub(r"[ \t]+", " ", text) | ||
| text = re.sub(r"\n{3,}", "\n\n", text) | ||
| return text.strip() | ||
|
|
||
|
|
||
| def _safe_limit(limit: Any) -> int: | ||
| if limit is None or limit == "": | ||
| return 10 | ||
| try: | ||
| limit = int(limit) | ||
| except (TypeError, ValueError): | ||
| return 10 | ||
| return max(1, min(limit, MAX_LIMIT)) | ||
|
|
||
|
|
||
| async def _request_json(path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: | ||
| async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client: | ||
| response = await client.get(f"{ALGOLIA_BASE_URL}{path}", params=params) | ||
| response.raise_for_status() | ||
| return response.json() | ||
|
|
||
|
|
||
| def _format_story(hit: dict[str, Any], index: int) -> str: | ||
| title = hit.get("title") or hit.get("story_title") or "(untitled)" | ||
| author = hit.get("author") or "unknown" | ||
| points = hit.get("points") or 0 | ||
| comments = hit.get("num_comments") or 0 | ||
| object_id = hit.get("objectID") or hit.get("story_id") | ||
| url = hit.get("url") or hit.get("story_url") or f"https://news.ycombinator.com/item?id={object_id}" | ||
|
|
||
| return ( | ||
| f"{index}. {title}\n" | ||
| f" by {author} | {points} points | {comments} comments\n" | ||
| f" {url}\n" | ||
| f" HN: https://news.ycombinator.com/item?id={object_id}" | ||
| ) | ||
|
|
||
|
|
||
| @app.get("/") | ||
| async def root(): | ||
| return HTMLResponse( | ||
| """ | ||
| <html> | ||
| <head><title>Hacker News x Omi</title></head> | ||
| <body style="font-family: sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5;"> | ||
| <h1>Hacker News x Omi</h1> | ||
| <p>Read the Hacker News front page, search stories, and fetch discussions from Omi.</p> | ||
| <p>No sign-in is required.</p> | ||
| </body> | ||
| </html> | ||
| """ | ||
| ) | ||
|
|
||
|
|
||
| @app.get("/health") | ||
| async def health(): | ||
| return {"status": "ok"} | ||
|
|
||
|
|
||
| @app.get("/.well-known/omi-tools.json") | ||
| async def get_omi_tools_manifest(): | ||
| return { | ||
| "tools": [ | ||
| { | ||
| "name": "get_front_page", | ||
| "description": "Get current Hacker News front page stories. Use this when the user asks for top tech/startup/programming news or Hacker News headlines.", | ||
| "endpoint": "/tools/get_front_page", | ||
| "method": "POST", | ||
| "parameters": { | ||
| "type": "object", | ||
| "properties": { | ||
| "limit": { | ||
| "type": "integer", | ||
| "description": "Maximum stories to return. Defaults to 10, maximum 20.", | ||
| } | ||
| }, | ||
| "required": [], | ||
| }, | ||
| "auth_required": False, | ||
| "status_message": "Fetching Hacker News front page...", | ||
| }, | ||
| { | ||
| "name": "search_stories", | ||
| "description": "Search Hacker News stories and discussions by keyword. Use this when the user mentions a company, project, technology, product, person, or topic and wants relevant HN discussions.", | ||
| "endpoint": "/tools/search_stories", | ||
| "method": "POST", | ||
| "parameters": { | ||
| "type": "object", | ||
| "properties": { | ||
| "query": { | ||
| "type": "string", | ||
| "description": "Search query, such as a project name, company, technology, or topic.", | ||
| }, | ||
| "limit": { | ||
| "type": "integer", | ||
| "description": "Maximum results to return. Defaults to 10, maximum 20.", | ||
| }, | ||
| "sort_by": { | ||
| "type": "string", | ||
| "enum": ["relevance", "date"], | ||
| "description": "Sort by relevance or date. Defaults to relevance.", | ||
| }, | ||
| }, | ||
| "required": ["query"], | ||
| }, | ||
| "auth_required": False, | ||
| "status_message": "Searching Hacker News...", | ||
| }, | ||
| { | ||
| "name": "get_discussion", | ||
| "description": "Fetch a Hacker News item and its top-level comments. Use this when the user wants details, comments, or discussion for a specific HN item ID.", | ||
| "endpoint": "/tools/get_discussion", | ||
| "method": "POST", | ||
| "parameters": { | ||
| "type": "object", | ||
| "properties": { | ||
| "item_id": { | ||
| "type": "integer", | ||
| "description": "Hacker News item ID.", | ||
| }, | ||
| "comment_limit": { | ||
| "type": "integer", | ||
| "description": "Maximum top-level comments to include. Defaults to 5, maximum 20.", | ||
| }, | ||
| }, | ||
| "required": ["item_id"], | ||
| }, | ||
| "auth_required": False, | ||
| "status_message": "Fetching Hacker News discussion...", | ||
| }, | ||
| ] | ||
| } | ||
|
|
||
|
|
||
| @app.post("/tools/get_front_page", tags=["chat_tools"], response_model=ChatToolResponse) | ||
| async def get_front_page(payload: dict[str, Any]): | ||
| try: | ||
| limit = _safe_limit(payload.get("limit")) | ||
| data = await _request_json("/search", {"tags": "front_page", "hitsPerPage": limit}) | ||
| hits = data.get("hits", [])[:limit] | ||
|
|
||
| if not hits: | ||
|
Comment on lines
+177
to
+189
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
All three route handlers ( |
||
| return ChatToolResponse(result="No Hacker News front page stories were returned.") | ||
|
|
||
| stories = [_format_story(hit, index) for index, hit in enumerate(hits, start=1)] | ||
| return ChatToolResponse(result="Current Hacker News front page:\n\n" + "\n\n".join(stories)) | ||
| except httpx.HTTPError as exc: | ||
| return ChatToolResponse(error=f"Hacker News request failed: {exc}") | ||
|
|
||
|
|
||
| @app.post("/tools/search_stories", tags=["chat_tools"], response_model=ChatToolResponse) | ||
| async def search_stories(payload: dict[str, Any]): | ||
| query = (payload.get("query") or "").strip() | ||
| if not query: | ||
| return ChatToolResponse(error="Missing required field: query") | ||
|
|
||
| try: | ||
| limit = _safe_limit(payload.get("limit")) | ||
| sort_by = payload.get("sort_by") or "relevance" | ||
| endpoint = "/search_by_date" if sort_by == "date" else "/search" | ||
| data = await _request_json(endpoint, {"query": query, "tags": "story", "hitsPerPage": limit}) | ||
| hits = data.get("hits", [])[:limit] | ||
|
|
||
| if not hits: | ||
| return ChatToolResponse(result=f"No Hacker News stories found for '{query}'.") | ||
|
|
||
| stories = [_format_story(hit, index) for index, hit in enumerate(hits, start=1)] | ||
| return ChatToolResponse(result=f"Hacker News stories for '{query}':\n\n" + "\n\n".join(stories)) | ||
| except httpx.HTTPError as exc: | ||
| return ChatToolResponse(error=f"Hacker News search failed: {exc}") | ||
|
|
||
|
|
||
| @app.post("/tools/get_discussion", tags=["chat_tools"], response_model=ChatToolResponse) | ||
| async def get_discussion(payload: dict[str, Any]): | ||
| item_id = payload.get("item_id") | ||
| if item_id is None: | ||
| return ChatToolResponse(error="Missing required field: item_id") | ||
|
|
||
| try: | ||
| comment_limit = _safe_limit(payload.get("comment_limit")) | ||
| item = await _request_json(f"/items/{int(item_id)}") | ||
|
|
||
| title = item.get("title") or "(untitled)" | ||
| author = item.get("author") or "unknown" | ||
| points = item.get("points") or 0 | ||
| url = item.get("url") or f"https://news.ycombinator.com/item?id={item_id}" | ||
|
Comment on lines
+230
to
+233
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The expression |
||
| comments = item.get("children", [])[:comment_limit] | ||
|
|
||
| lines = [ | ||
| f"{title}", | ||
| f"by {author} | {points} points", | ||
| url, | ||
| f"HN: https://news.ycombinator.com/item?id={item_id}", | ||
| ] | ||
|
|
||
| text = _clean_text(item.get("text")) | ||
| if text: | ||
| lines.extend(["", "Post text:", text]) | ||
|
|
||
| if comments: | ||
| lines.append("") | ||
| lines.append(f"Top {len(comments)} comments:") | ||
| for index, comment in enumerate(comments, start=1): | ||
| comment_author = comment.get("author") or "unknown" | ||
| comment_text = _clean_text(comment.get("text")) | ||
| if comment_text: | ||
| lines.append(f"\n{index}. {comment_author}: {comment_text[:1200]}") | ||
| else: | ||
| lines.extend(["", "No top-level comments returned."]) | ||
|
|
||
| return ChatToolResponse(result="\n".join(lines)) | ||
| except (ValueError, TypeError): | ||
| return ChatToolResponse(error="item_id must be an integer") | ||
| except httpx.HTTPError as exc: | ||
| return ChatToolResponse(error=f"Hacker News discussion request failed: {exc}") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| [build] | ||
| builder = "NIXPACKS" | ||
|
|
||
| [deploy] | ||
| startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT" | ||
| healthcheckPath = "/health" | ||
| healthcheckTimeout = 300 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| fastapi==0.115.6 | ||
| uvicorn[standard]==0.34.0 | ||
| httpx==0.28.1 | ||
| pydantic==2.10.4 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| python-3.11.9 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The HN Algolia API returns comments with a range of HTML elements beyond the 8 replaced here — most commonly
<a href="...">…</a>,<br>,<b>,<strong>, and<span>. These pass through_clean_textunchanged, so tool responses will contain literal HTML markup. A simplere.sub(r"<[^>]+>", "", text)after handling structural tags would cover the general case.