Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ htmlcov/

# Bandit reports
bandit-report.json
validation_output.txt
88 changes: 88 additions & 0 deletions community/upwork-job-search/README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions community/upwork-job-search/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

4 changes: 4 additions & 0 deletions community/upwork-job-search/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"unique_name": "upwork-job-search",
"matching_hotwords": ["find jobs", "search upwork", "find freelance work", "upwork jobs", "look for work"]
}
1 change: 1 addition & 0 deletions community/upwork-job-search/images/badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
191 changes: 191 additions & 0 deletions community/upwork-job-search/main.py
Original file line number Diff line number Diff line change
@@ -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("&nbsp;", " ")
text = text.replace("&amp;", "&")
text = text.replace("&lt;", "<")
text = text.replace("&gt;", ">")
text = text.replace("&quot;", '"')
text = text.replace("&#39;", "'")
# 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()
48 changes: 48 additions & 0 deletions community/upwork-job-search/run_local.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
Empty file.
27 changes: 27 additions & 0 deletions community/upwork-job-search/src/agent/capability.py
Original file line number Diff line number Diff line change
@@ -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")
Loading