diff --git a/.gitignore b/.gitignore index b09c1b2..73b9911 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ htmlcov/ # Bandit reports bandit-report.json +validation_output.txt diff --git a/community/upwork-job-search/README.md b/community/upwork-job-search/README.md new file mode 100644 index 0000000..b95423b --- /dev/null +++ b/community/upwork-job-search/README.md @@ -0,0 +1,88 @@ +# Upwork Job Search + +![Abilities Badge](https://img.shields.io/badge/Abilities-Open-green.svg)io%2Fbadge%2FAbilities-Open-green) +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-@khushi0433-lightgrey?style=flat-square) + +## What It Does +Search for freelance jobs on Upwork using voice. Simply say what type of job you're looking for (like "web development" or "Python programming"), and this Ability will find the top Upwork jobs and read them out to you. + +## Suggested Trigger Words +- "find jobs" +- "search upwork" +- "find freelance work" +- "upwork jobs" +- "look for work" + +## Setup + +### Prerequisites +- An Upwork API application registered at [developers.upwork.com](https://developers.upwork.com) + +### API Credentials +1. Go to [developers.upwork.com](https://developers.upwork.com) and create a new app +2. Get your **Client Key** and **Client Secret** +3. Edit `main.py` and replace: + +``` +python + UPWORK_CLIENT_KEY = "YOUR_UPWORK_CLIENT_KEY" + UPWORK_CLIENT_SECRET = "YOUR_UPWORK_CLIENT_SECRET" + +``` + +### How to Get Upwork API Access +1. Register at [developers.upwork.com](https://developers.upwork.com) +2. Create a new application with: + - App Name: `OpenHome Upwork Jobs` + - Description: Voice AI ability to search Upwork jobs + - Callback URL: `https://localhost/callback` + - App URL: `https://app.openhome.com` +3. Once approved, you'll receive Client Key and Client Secret +4. For production use, you'll need to implement proper OAuth 2.0 flow + +## How It Works + +``` +User activates ability with trigger word + Ability asks "What type of jobs are you looking for?" + User responds with a category (e.g., "web development") + Ability searches Upwork API + Ability reads out top job results with: + - Job title + - Budget + - Duration + - Workload + - Client rating + → User can search again or exit +``` + +## Example Conversation + +> User: "find jobs" +> AI: "I can help you find freelance jobs on Upwork. What type of jobs are you looking for? For example, web development, mobile app, data entry, or copy writing." +> User: "web development" +> AI: "Searching for web development jobs on Upwork... I found 5 jobs matching 'web development'. Here are the top results:" +> AI: "Job 1: Build a WordPress Website. Budget: 500 USD. Duration: 1-3 months. Workload: Not specified. Client rating: 4.5 out of 5." +> AI: "Job 2: React Frontend Developer Needed. Budget: 1000 USD. Duration: 1-4 weeks. Workload: More than 30 hrs/week. Client rating: 5 out of 5." +> AI: "Would you like me to search for a different category? Say stop to exit." +> User: "no thanks" +> AI: "Happy job hunting! Talk to you later." + +## Important Notes + +- This Ability uses the Upwork GraphQL API (`api.upwork.com/graphql/v2`) +- For full functionality, you need Upwork API credentials with appropriate permissions +- Jobs are limited to top 5 results to keep voice responses concise +- The ability includes error handling for API failures and provides user-friendly messages + +## Technical Details + +- API Used: Upwork GraphQL API v2 +- Authentication: OAuth 2.0 (client credentials flow) +- Dependencies: `requests` library +- Pattern: API Template (Speak → Input → API Call → Speak Result → Exit) + +## License + +MIT License - See LICENSE file for details. diff --git a/community/upwork-job-search/__init__.py b/community/upwork-job-search/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/community/upwork-job-search/__init__.py @@ -0,0 +1 @@ + diff --git a/community/upwork-job-search/config.json b/community/upwork-job-search/config.json new file mode 100644 index 0000000..39d1554 --- /dev/null +++ b/community/upwork-job-search/config.json @@ -0,0 +1,4 @@ +{ + "unique_name": "upwork-job-search", + "matching_hotwords": ["find jobs", "search upwork", "find freelance work", "upwork jobs", "look for work"] +} diff --git a/community/upwork-job-search/images/badge.svg b/community/upwork-job-search/images/badge.svg new file mode 100644 index 0000000..06c8460 --- /dev/null +++ b/community/upwork-job-search/images/badge.svg @@ -0,0 +1 @@ +Abilities: OpenAbilitiesOpen \ No newline at end of file diff --git a/community/upwork-job-search/main.py b/community/upwork-job-search/main.py new file mode 100644 index 0000000..dcfe1f3 --- /dev/null +++ b/community/upwork-job-search/main.py @@ -0,0 +1,191 @@ +import json +import os +import re +from typing import Optional, List +from urllib.parse import urlencode + +import httpx + +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +# Remotive public API — free, no authentication required +REMOTIVE_API_URL = "https://remotive.com/api/remote-jobs" + + +class UpworkJobSearchCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # ---------------- REGISTER ---------------- + + @classmethod + def register_capability(cls) -> "MatchingCapability": + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + ) as file: + data = json.load(file) + + return cls( + unique_name=data["unique_name"], + matching_hotwords=data["matching_hotwords"], + ) + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run()) + + # ---------------- HELPERS ---------------- + + def _clean_html(self, text: str) -> str: + """Strip HTML tags and decode common HTML entities.""" + # Replace block-level tags with a space so adjacent words don't merge + text = re.sub(r"<(br|p|div|li|tr|td|th|h[1-6])[^>]*>", " ", text, flags=re.IGNORECASE) + # Strip remaining tags + text = re.sub(r"<[^>]+>", "", text) + # Decode common HTML entities + text = text.replace(" ", " ") + text = text.replace("&", "&") + text = text.replace("<", "<") + text = text.replace(">", ">") + text = text.replace(""", '"') + text = text.replace("'", "'") + # Collapse whitespace + text = re.sub(r"\s+", " ", text) + return text.strip() + + # ---------------- SEARCH ---------------- + + async def _fetch_jobs_for_query(self, client: httpx.AsyncClient, query: str) -> list: + """Single HTTP call to Remotive; returns raw job list (may be empty).""" + url = f"{REMOTIVE_API_URL}?{urlencode({'search': query, 'limit': 5})}" + response = await client.get( + url, + headers={ + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "application/json", + }, + ) + if response.status_code != 200: + self.worker.editor_logging_handler.error( + f"[UpworkJobSearch] HTTP {response.status_code}: {response.text[:200]}" + ) + return [] + return response.json().get("jobs", []) + + async def search_jobs(self, query: str) -> Optional[List[dict]]: + """Fetch the top 5 remote jobs matching the query from the Remotive API. + + Tries the full phrase first; if no results are returned, retries with + just the most significant keyword (first word) as a fallback. + """ + try: + async with httpx.AsyncClient(timeout=30.0) as client: + raw_jobs = await self._fetch_jobs_for_query(client, query) + + # Fallback: retry with only the first keyword when the full phrase + # yields nothing (Remotive does phrase-level matching). + if not raw_jobs: + first_keyword = query.strip().split()[0] if query.strip() else "" + if first_keyword and first_keyword.lower() != query.strip().lower(): + raw_jobs = await self._fetch_jobs_for_query(client, first_keyword) + + if not raw_jobs: + return None + + jobs = [] + for item in raw_jobs[:5]: + description = self._clean_html(item.get("description", "")) + if len(description) > 300: + description = description[:300] + "..." + + jobs.append( + { + "title": item.get("title", "Untitled"), + "company": item.get("company_name", "Unknown company"), + "description": description, + "link": item.get("url", ""), + "pub_date": item.get("publication_date", ""), + "salary": item.get("salary", "Not specified"), + "location": item.get("candidate_required_location", "Remote"), + "job_type": item.get("job_type", ""), + } + ) + + return jobs + + except Exception as e: + self.worker.editor_logging_handler.error( + f"[UpworkJobSearch] Network error: {e}" + ) + return None + + # ---------------- FORMAT ---------------- + + def format_job_for_speech(self, job: dict, index: int) -> str: + title = job.get("title", "Untitled") + company = job.get("company", "Unknown company") + location = job.get("location", "Remote") + salary = job.get("salary", "Not specified") + description = job.get("description", "No description available.") + short_desc = description[:150].rstrip() + + salary_part = f" Salary: {salary}." if salary and salary != "Not specified" else "" + return ( + f"Job {index + 1}: {title} at {company}. " + f"Location: {location}.{salary_part} " + f"{short_desc}." + ) + + # ---------------- MAIN FLOW ---------------- + + async def run(self): + try: + await self.capability_worker.speak( + "I can help you find remote freelance jobs. " + "What type of jobs are you looking for?" + ) + + user_input = await self.capability_worker.user_response() + + if not user_input or not user_input.strip(): + await self.capability_worker.speak( + "I didn't catch that. Please try again." + ) + return + + await self.capability_worker.speak( + f"Searching for {user_input} jobs..." + ) + + jobs = await self.search_jobs(user_input) + + if jobs: + await self.capability_worker.speak( + f"I found {len(jobs)} jobs. Here are the top results." + ) + for i, job in enumerate(jobs[:3]): + await self.capability_worker.speak( + self.format_job_for_speech(job, i) + ) + else: + await self.capability_worker.speak( + "I couldn't find any jobs matching that search." + ) + + except Exception as e: + self.worker.editor_logging_handler.error( + f"[UpworkJobSearch] Fatal error: {e}" + ) + await self.capability_worker.speak( + "Something went wrong while searching." + ) + + finally: + self.capability_worker.resume_normal_flow() diff --git a/community/upwork-job-search/run_local.py b/community/upwork-job-search/run_local.py new file mode 100644 index 0000000..1591a0c --- /dev/null +++ b/community/upwork-job-search/run_local.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" +Local test runner for upwork-job-search ability. +This uses mock SDK classes to simulate the OpenHome environment. +""" +from main import UpworkJobSearchCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker +import asyncio +import sys +import os + +# Add the ability directory to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +async def main(): + print("=" * 60) + print(" Upwork Job Search - Local Test Runner") + print("=" * 60) + print() + + # Create the worker (mock) + worker = AgentWorker() + + # Register and instantiate the capability + capability = UpworkJobSearchCapability.register_capability() + + # Set up the worker references + capability.worker = worker + capability.capability_worker = CapabilityWorker(worker) + + # Run the capability + print("Starting ability...\n") + try: + await capability.run() + except Exception as e: + print(f"\n Error: {e}") + import traceback + traceback.print_exc() + + print("\n" + "=" * 60) + print("Test runner finished") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/community/upwork-job-search/src/__init__.py b/community/upwork-job-search/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/community/upwork-job-search/src/agent/__init__.py b/community/upwork-job-search/src/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/community/upwork-job-search/src/agent/capability.py b/community/upwork-job-search/src/agent/capability.py new file mode 100644 index 0000000..3df8524 --- /dev/null +++ b/community/upwork-job-search/src/agent/capability.py @@ -0,0 +1,27 @@ +# Mock SDK - Capability module + +class MatchingCapability: + """Mock base class for MatchingCapability""" + worker: 'AgentWorker' = None + capability_worker: 'CapabilityWorker' = None + unique_name: str = "" + matching_hotwords: list = [] + + def __init__(self, unique_name: str = "", matching_hotwords: list = None): + self.unique_name = unique_name + self.matching_hotwords = matching_hotwords or [] + self.worker = None + self.capability_worker = None + + @classmethod + def register_capability(cls) -> "MatchingCapability": + """Register this capability - must be implemented by subclass""" + raise NotImplementedError("Subclasses must implement register_capability") + + def call(self, worker: 'AgentWorker'): + """Called when the capability is triggered""" + raise NotImplementedError("Subclasses must implement call") + + async def run(self): + """Main execution logic - must be implemented by subclass""" + raise NotImplementedError("Subclasses must implement run") diff --git a/community/upwork-job-search/src/agent/capability_worker.py b/community/upwork-job-search/src/agent/capability_worker.py new file mode 100644 index 0000000..bf5f515 --- /dev/null +++ b/community/upwork-job-search/src/agent/capability_worker.py @@ -0,0 +1,44 @@ +# Mock SDK - CapabilityWorker module + +class CapabilityWorker: + """Mock CapabilityWorker class for local development""" + + def __init__(self, worker): + self.worker = worker + + async def speak(self, text: str): + """Speak text to the user""" + print(f"AI: {text}") + + async def user_response(self) -> str: + """Get user's voice response""" + return input("👤 User: ") + + async def wait_for_complete_transcription(self) -> str: + """Wait for complete transcription""" + return input("👤 User: ") + + def resume_normal_flow(self): + """Resume normal conversation flow""" + print("Resuming normal flow...") + + async def text_to_text_response(self, prompt: str) -> str: + """Get text-to-text response from LLM""" + print(f"LLM Prompt: {prompt}") + return input("LLM Response: ") + + # File operations (mock) + async def read_file(self, path: str) -> str: + """Read a file""" + with open(path, 'r') as f: + return f.read() + + async def write_file(self, path: str, content: str): + """Write to a file""" + with open(path, 'w') as f: + f.write(content) + + async def file_exists(self, path: str) -> bool: + """Check if file exists""" + import os + return os.path.exists(path) diff --git a/community/upwork-job-search/src/main.py b/community/upwork-job-search/src/main.py new file mode 100644 index 0000000..111e301 --- /dev/null +++ b/community/upwork-job-search/src/main.py @@ -0,0 +1,37 @@ +# Mock SDK - Main module + +class AgentWorker: + """Mock AgentWorker class for local development""" + session_tasks: 'SessionTasks' = None + editor_logging_handler: 'LoggingHandler' = None + + def __init__(self): + self.session_tasks = SessionTasks() + self.editor_logging_handler = LoggingHandler() + + +class SessionTasks: + """Mock SessionTasks class""" + + def create(self, coroutine): + """Create a task from coroutine""" + import asyncio + asyncio.create_task(coroutine) + + def sleep(self, seconds: float): + """Async sleep""" + import asyncio + return asyncio.sleep(seconds) + + +class LoggingHandler: + """Mock LoggingHandler class""" + + def error(self, msg: str): + print(f"ERROR: {msg}") + + def info(self, msg: str): + print(f"INFO: {msg}") + + def warning(self, msg: str): + print(f"WARNING: {msg}") diff --git a/community/upwork-job-search/test_ability.py b/community/upwork-job-search/test_ability.py new file mode 100644 index 0000000..777fffb --- /dev/null +++ b/community/upwork-job-search/test_ability.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +Automated tests for the Upwork Job Search ability (Remotive API backend). +Covers: imports, _clean_html, search_jobs (live API), format_job_for_speech. +""" +from main import UpworkJobSearchCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker +import asyncio +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +PASS = "\033[92mPASS\033[0m" +FAIL = "\033[91mFAIL\033[0m" + +results = [] + + +def report(name: str, passed: bool, detail: str = ""): + status = PASS if passed else FAIL + print(f" [{status}] {name}" + (f" — {detail}" if detail else "")) + results.append(passed) + + +def make_capability() -> UpworkJobSearchCapability: + worker = AgentWorker() + cap = UpworkJobSearchCapability.register_capability() + cap.worker = worker + cap.capability_worker = CapabilityWorker(worker) + return cap + + +# ───────────────────────────────────────────── +# TEST 1 — Module imports correctly +# ───────────────────────────────────────────── +def test_import(): + print("\n[1] Module import") + try: + cap = make_capability() + report("UpworkJobSearchCapability instantiated", cap is not None) + report("unique_name loaded from config.json", bool(cap.unique_name)) + report( + "matching_hotwords is a non-empty list", + isinstance(cap.matching_hotwords, list) and len(cap.matching_hotwords) > 0, + ) + except Exception as e: + report("import / instantiation", False, str(e)) + + +# ───────────────────────────────────────────── +# TEST 2 — _clean_html +# ───────────────────────────────────────────── +def test_clean_html(): + print("\n[2] _clean_html()") + cap = make_capability() + + cases = [ + ("Hello world", "Hello world"), + ("

Line 1

Line 2

", "Line 1 Line 2"), + ("
New line", "New line"), + ("No tags here", "No tags here"), + ("A & B <3>", "A & B <3>"), + (" spaced ", "spaced"), + ('link', "link"), + ("", ""), + ("", "item 1 item 2"), + ] + + for raw, expected in cases: + result = cap._clean_html(raw) + passed = result == expected + report(f"_clean_html({raw!r})", passed, f"got {result!r}" if not passed else "") + + +# ───────────────────────────────────────────── +# TEST 3 — format_job_for_speech +# ───────────────────────────────────────────── +def test_format_job_for_speech(): + print("\n[3] format_job_for_speech()") + cap = make_capability() + + job = { + "title": "Python Developer", + "company": "Acme Corp", + "description": "Build REST APIs using FastAPI and PostgreSQL.", + "link": "https://remotive.com/job/123", + "pub_date": "2024-01-01T00:00:00", + "salary": "$80k - $120k", + "location": "Worldwide", + "job_type": "full_time", + } + + speech = cap.format_job_for_speech(job, 0) + report("returns a string", isinstance(speech, str)) + report("contains job index (Job 1)", "Job 1" in speech) + report("contains title", "Python Developer" in speech) + report("contains company name", "Acme Corp" in speech) + report("contains location", "Worldwide" in speech) + report("contains salary", "$80k" in speech) + report("contains description snippet", "FastAPI" in speech) + report("not empty", bool(speech.strip())) + + # No salary case + job_no_salary = {**job, "salary": "Not specified"} + speech2 = cap.format_job_for_speech(job_no_salary, 1) + report("omits salary line when not specified", "Salary:" not in speech2) + report("still contains title when no salary", "Python Developer" in speech2) + + +# ───────────────────────────────────────────── +# TEST 4 — search_jobs live API call +# ───────────────────────────────────────────── +async def test_search_jobs(): + print("\n[4] search_jobs() — live Remotive API call") + cap = make_capability() + + jobs = await cap.search_jobs("software engineer") + + report("returns a list (not None)", jobs is not None, "" if jobs else "got None — check network") + + if jobs is None: + report("skipping further job checks (no results)", False) + return + + report("returns at most 5 jobs", len(jobs) <= 5, f"got {len(jobs)}") + report("returns at least 1 job", len(jobs) >= 1, f"got {len(jobs)}") + + job = jobs[0] + required_keys = ["title", "company", "description", "link", "pub_date", "salary", "location"] + for key in required_keys: + report(f"first job has '{key}' key", key in job) + + report("title is a non-empty string", isinstance(job["title"], str) and bool(job["title"])) + report("description has no raw HTML tags", "<" not in job["description"]) + report( + "description truncated to <=303 chars", + len(job["description"]) <= 303, + f"len={len(job['description'])}", + ) + report( + "link starts with https://", + job["link"].startswith("https://"), + f"got {job['link'][:60]}", + ) + + print("\n Sample job fetched:") + print(f" Title : {job['title']}") + print(f" Company : {job['company']}") + print(f" Location : {job['location']}") + print(f" Salary : {job['salary']}") + print(f" Published: {job['pub_date']}") + print(f" Desc : {job['description'][:120]}...") + print(f" Link : {job['link']}") + + +# ───────────────────────────────────────────── +# TEST 5 — search_jobs edge cases +# ───────────────────────────────────────────── +async def test_edge_cases(): + print("\n[5] search_jobs() — edge cases") + cap = make_capability() + + # A nonsense query — should return None or an empty-list-derived None, not crash + jobs = await cap.search_jobs("zzzzzzzzzzzzz_no_results_expected_xyzxyz") + report( + "nonsense query returns None or list (no crash)", + jobs is None or isinstance(jobs, list), + ) + + # Empty string — should not crash + jobs_empty = await cap.search_jobs("") + report( + "empty query returns None or list (no crash)", + jobs_empty is None or isinstance(jobs_empty, list), + ) + + # Multi-word phrase that triggers the keyword fallback + jobs_fallback = await cap.search_jobs("python developer") + report( + "multi-word fallback returns None or list (no crash)", + jobs_fallback is None or isinstance(jobs_fallback, list), + ) + if jobs_fallback: + report( + "fallback result has 'title' key", + "title" in jobs_fallback[0], + ) + + +# ───────────────────────────────────────────── +# RUNNER +# ───────────────────────────────────────────── +async def run_all(): + print("=" * 60) + print(" Upwork Job Search — Ability Test Suite") + print("=" * 60) + + test_import() + test_clean_html() + test_format_job_for_speech() + await test_search_jobs() + await test_edge_cases() + + total = len(results) + passed = sum(results) + failed = total - passed + + print("\n" + "=" * 60) + if failed == 0: + print(f" Results: {passed}/{total} passed — ALL TESTS PASSED") + else: + print(f" Results: {passed}/{total} passed ({failed} FAILED)") + print("=" * 60) + + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + asyncio.run(run_all()) diff --git a/validate_ability.py b/validate_ability.py index 0eb8e42..7ff493e 100644 --- a/validate_ability.py +++ b/validate_ability.py @@ -210,7 +210,8 @@ def main(): full_output = "\n".join(output_lines) - with open(output_file, "w") as f: + # using UTF-8 encoding to ensure emojis are written correctly + with open(output_file, "w", encoding="utf-8") as f: f.write(full_output) if summary_file: