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 + +io%2Fbadge%2FAbilities-Open-green) + + + +## 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 @@ + \ 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"), + ("