diff --git a/.gitignore b/.gitignore index 1a02df1..a80c197 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ __pycache__/ # macOS system files .DS_Store + +.myenv/ diff --git a/examples/acp_base/external_evaluation/.env.example b/examples/acp_base/external_evaluation/.env.example index c3f2708..087d3d8 100644 --- a/examples/acp_base/external_evaluation/.env.example +++ b/examples/acp_base/external_evaluation/.env.example @@ -1,4 +1,11 @@ -WHITELISTED_WALLET_PRIVATE_KEY= +BUYER_WALLET_PRIVATE_KEY= BUYER_AGENT_WALLET_ADDRESS= +BUYER_GAME_TWITTER_ACCESS_TOKEN= + +SELLER_WALLET_PRIVATE_KEY= SELLER_AGENT_WALLET_ADDRESS= -EVALUATOR_AGENT_WALLET_ADDRESS= \ No newline at end of file +SELLER_GAME_TWITTER_ACCESS_TOKEN= + +EVALUATOR_WALLET_PRIVATE_KEY= +EVALUATOR_AGENT_WALLET_ADDRESS= +EVALUATOR_GAME_TWITTER_ACCESS_TOKEN= \ No newline at end of file diff --git a/examples/acp_base/external_evaluation/buyer.py b/examples/acp_base/external_evaluation/buyer.py index 7c62d41..c5dfff2 100644 --- a/examples/acp_base/external_evaluation/buyer.py +++ b/examples/acp_base/external_evaluation/buyer.py @@ -23,12 +23,12 @@ def on_new_task(job: ACPJob): break elif job.phase == ACPJobPhase.COMPLETED: print("Job completed", job) - acp = VirtualsACP( - wallet_private_key=env.WHITELISTED_WALLET_PRIVATE_KEY, + wallet_private_key=env.BUYER_WALLET_PRIVATE_KEY, agent_wallet_address=env.BUYER_AGENT_WALLET_ADDRESS, config=BASE_SEPOLIA_CONFIG, - on_new_task=on_new_task + on_new_task=on_new_task, + game_twitter_access_token=env.BUYER_GAME_TWITTER_ACCESS_TOKEN ) # Browse available agents based on a keyword and cluster name @@ -45,7 +45,7 @@ def on_new_task(job: ACPJob): # Reference: (./images/specify_requirement_toggle_switch.png) service_requirement={"": "Help me to generate a flower meme."}, evaluator_address=env.EVALUATOR_AGENT_WALLET_ADDRESS, - expired_at=datetime.now() + timedelta(days=1) + expired_at=datetime.now() + timedelta(days=1), ) print(f"Job {job_id} initiated") diff --git a/examples/acp_base/external_evaluation/evaluator.py b/examples/acp_base/external_evaluation/evaluator.py index 2c38238..9f27a5c 100644 --- a/examples/acp_base/external_evaluation/evaluator.py +++ b/examples/acp_base/external_evaluation/evaluator.py @@ -13,7 +13,6 @@ def evaluator(): def on_evaluate(job: ACPJob): # Find the deliverable memo for memo in job.memos: - print(memo.next_phase, ACPJobPhase.COMPLETED) if memo.next_phase == ACPJobPhase.COMPLETED: print("Evaluating deliverable", job.id) job.evaluate(True) @@ -21,7 +20,7 @@ def on_evaluate(job: ACPJob): # Initialize the ACP client acp_client = VirtualsACP( - wallet_private_key=env.WHITELISTED_WALLET_PRIVATE_KEY, + wallet_private_key=env.EVALUATOR_WALLET_PRIVATE_KEY, agent_wallet_address=env.EVALUATOR_AGENT_WALLET_ADDRESS, config=BASE_SEPOLIA_CONFIG, on_evaluate=on_evaluate diff --git a/examples/acp_base/external_evaluation/seller.py b/examples/acp_base/external_evaluation/seller.py index c5775fe..f3af541 100644 --- a/examples/acp_base/external_evaluation/seller.py +++ b/examples/acp_base/external_evaluation/seller.py @@ -32,10 +32,11 @@ def on_new_task(job: ACPJob): # Initialize the ACP client acp_client = VirtualsACP( - wallet_private_key=env.WHITELISTED_WALLET_PRIVATE_KEY, + wallet_private_key=env.SELLER_WALLET_PRIVATE_KEY, agent_wallet_address=env.SELLER_AGENT_WALLET_ADDRESS, config=BASE_SEPOLIA_CONFIG, - on_new_task=on_new_task + on_new_task=on_new_task, + game_twitter_access_token=env.SELLER_GAME_TWITTER_ACCESS_TOKEN ) # Keep the script running to listen for new tasks diff --git a/examples/acp_base/self_evaluation/.env.example b/examples/acp_base/self_evaluation/.env.example index 6977bfb..bf4a825 100644 --- a/examples/acp_base/self_evaluation/.env.example +++ b/examples/acp_base/self_evaluation/.env.example @@ -1,3 +1,7 @@ -WHITELISTED_WALLET_PRIVATE_KEY= +BUYER_WALLET_PRIVATE_KEY= BUYER_AGENT_WALLET_ADDRESS= -SELLER_AGENT_WALLET_ADDRESS= \ No newline at end of file +BUYER_GAME_TWITTER_ACCESS_TOKEN= + +SELLER_WALLET_PRIVATE_KEY= +SELLER_AGENT_WALLET_ADDRESS= +SELLER_GAME_TWITTER_ACCESS_TOKEN= diff --git a/examples/acp_base/self_evaluation/buyer.py b/examples/acp_base/self_evaluation/buyer.py index 53b845f..cdcab8b 100644 --- a/examples/acp_base/self_evaluation/buyer.py +++ b/examples/acp_base/self_evaluation/buyer.py @@ -27,7 +27,6 @@ def on_new_task(job: ACPJob): print("Job completed", job) def on_evaluate(job: ACPJob): - print("Evaluation function called", job.memos) # Find the deliverable memo for memo in job.memos: if memo.next_phase == ACPJobPhase.COMPLETED: @@ -36,11 +35,12 @@ def on_evaluate(job: ACPJob): break acp = VirtualsACP( - wallet_private_key=env.WHITELISTED_WALLET_PRIVATE_KEY, + wallet_private_key=env.BUYER_WALLET_PRIVATE_KEY, agent_wallet_address=env.BUYER_AGENT_WALLET_ADDRESS, config=BASE_SEPOLIA_CONFIG, on_new_task=on_new_task, - on_evaluate=on_evaluate + on_evaluate=on_evaluate, + game_twitter_access_token=env.BUYER_GAME_TWITTER_ACCESS_TOKEN ) # Browse available agents based on a keyword and cluster name @@ -58,7 +58,7 @@ def on_evaluate(job: ACPJob): # Reference: (./images/specify_requirement_toggle_switch.png) service_requirement={"": "Help me to generate a flower meme."}, evaluator_address=env.BUYER_AGENT_WALLET_ADDRESS, - expired_at=datetime.now() + timedelta(days=1) + expired_at=datetime.now() + timedelta(days=1), ) print(f"Job {job_id} initiated") diff --git a/examples/acp_base/self_evaluation/seller.py b/examples/acp_base/self_evaluation/seller.py index e16e39d..2ef75b8 100644 --- a/examples/acp_base/self_evaluation/seller.py +++ b/examples/acp_base/self_evaluation/seller.py @@ -33,10 +33,11 @@ def on_new_task(job: ACPJob): # Initialize the ACP client acp_client = VirtualsACP( - wallet_private_key=env.WHITELISTED_WALLET_PRIVATE_KEY, + wallet_private_key=env.SELLER_WALLET_PRIVATE_KEY, agent_wallet_address=env.SELLER_AGENT_WALLET_ADDRESS, config=BASE_SEPOLIA_CONFIG, - on_new_task=on_new_task + on_new_task=on_new_task, + game_twitter_access_token=env.SELLER_GAME_TWITTER_ACCESS_TOKEN ) # Keep the script running to listen for new tasks diff --git a/pyproject.toml b/pyproject.toml index dc488ba..1a1f34a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ python-socketio = "^5.11.1" websocket-client = "^1.7.0" jsonschema = "^4.22.0" pydantic-settings = "^2.0" +virtuals-tweepy = "^0.1.6" [build-system] requires = ["poetry-core"] diff --git a/virtuals_acp/client.py b/virtuals_acp/client.py index f639c5c..ec681ac 100644 --- a/virtuals_acp/client.py +++ b/virtuals_acp/client.py @@ -20,6 +20,7 @@ from virtuals_acp.offering import ACPJobOffering from virtuals_acp.job import ACPJob from virtuals_acp.memo import ACPMemo +import virtuals_tweepy class VirtualsACP: def __init__(self, @@ -27,7 +28,9 @@ def __init__(self, agent_wallet_address: Optional[str] = None, config: Optional[ACPContractConfig] = DEFAULT_CONFIG, on_new_task: Optional[callable] = None, - on_evaluate: Optional[callable] = None): + on_evaluate: Optional[callable] = None, + game_twitter_access_token: Optional[str] = None, + ): self.config = config self.w3 = Web3(Web3.HTTPProvider(config.rpc_url)) @@ -50,6 +53,12 @@ def __init__(self, self.contract_manager = _ACPContractManager(self.w3, config, wallet_private_key) self.acp_api_url = config.acp_api_url + self.game_twitter_client = None + if (game_twitter_access_token): + self.game_twitter_client = virtuals_tweepy.Client( + game_twitter_access_token = game_twitter_access_token + ) + # Socket.IO setup self.on_new_task = on_new_task self.on_evaluate = on_evaluate or self._default_on_evaluate @@ -79,6 +88,8 @@ def _on_evaluate(self, data): acp_client=self, id=data["id"], provider_address=data["providerAddress"], + client_address=data["clientAddress"], + evaluator_address=data["evaluatorAddress"], memos=memos, phase=data["phase"], price=data["price"] @@ -98,11 +109,12 @@ def _on_new_task(self, data): next_phase=memo["nextPhase"], ) for memo in data["memos"]] - job = ACPJob( acp_client=self, id=data["id"], provider_address=data["providerAddress"], + client_address=data["clientAddress"], + evaluator_address=data["evaluatorAddress"], memos=memos, phase=data["phase"], price=data["price"] @@ -176,6 +188,7 @@ def browse_agents(self, keyword: str, cluster: Optional[str] = None) -> List[IAC provider_address=agent_data["walletAddress"], type=off["name"], price=off["price"], + agent_twitter_handle=agent_data.get("twitterHandle"), requirementSchema=off.get("requirementSchema", None) ) for off in agent_data.get("offerings", []) @@ -201,7 +214,8 @@ def initiate_job( service_requirement: Union[Dict[str, Any], str], amount: float, evaluator_address: Optional[str] = None, - expired_at: Optional[datetime] = None + expired_at: Optional[datetime] = None, + twitter_handle: Optional[str] = None ) -> int: if expired_at is None: expired_at = datetime.now(timezone.utc) + timedelta(days=1) @@ -261,6 +275,25 @@ def initiate_job( ) print(f"Initial memo for job {job_id} created.") + if (self.game_twitter_client): + if (not twitter_handle): + raise Exception("Provider twitter handle is required") + + try: + get_provider_user_fn = self.game_twitter_client.get_user + provider_user = get_provider_user_fn(username=twitter_handle) + user_id = provider_user.data.get("id", None) + if (user_id is None): + raise Exception(f"Unable to find user {twitter_handle} on Twitter") + + result =self.game_twitter_client.follow(user_id) + + if (not result.data.get("following")): + raise Exception(f"Failed to follow {twitter_handle}") + except Exception as e: + print(f"Error following {twitter_handle}: {e}") + raise e + payload = { "jobId": job_id, "clientAddress": self.agent_address, @@ -281,6 +314,7 @@ def initiate_job( "Content-Type": "application/json", } ) + #todo : move twitter logic here return job_id def respond_to_job_memo( @@ -288,9 +322,9 @@ def respond_to_job_memo( job_id: int, memo_id: int, accept: bool, - reason: Optional[str] = "" + reason: Optional[str] = "", + twitter_handle: Optional[str] = None ) -> str: - try: tx_hash = self.contract_manager.sign_memo(self.agent_address, memo_id, accept, reason or "") time.sleep(10) @@ -304,7 +338,27 @@ def respond_to_job_memo( is_secured=False, next_phase=ACPJobPhase.TRANSACTION ) + #todo : move twitter logic here + print(f"Responded to job {job_id} with memo {memo_id} and accept {accept} and reason {reason}") + + if (self.game_twitter_client and accept is True): + try: + if (not twitter_handle): + raise Exception("Client twitter handle is required") + + get_user_fn = self.game_twitter_client.get_user + user = get_user_fn(username=twitter_handle) + user_id = user.data.get("id", None) + if (not user_id): + raise Exception(f"Unable to find user {twitter_handle} on Twitter") + + result =self.game_twitter_client.follow(user_id) + + if (not result.data.get("following")): + raise Exception(f"Failed to follow {twitter_handle}") + except Exception as e: + raise e return tx_hash except Exception as e: print(f"Error in respond_to_job_memo: {e}") @@ -404,7 +458,7 @@ def get_active_jobs(self, page: int = 1, pageSize: int = 10) -> List["ACPJob"]: def get_completed_jobs(self, page: int = 1, pageSize: int = 10) -> List["ACPJob"]: - url = f"{self.acp_api_url}/jobs/completed?pagination[page]=${page}&pagination[pageSize]=${pageSize}" + url = f"{self.acp_api_url}/jobs/completed?pagination[page]={page}&pagination[pageSize]={pageSize}" headers = { "wallet-address": self.agent_address } @@ -525,6 +579,46 @@ def get_memo_by_id(self, onchain_job_id: int, memo_id: int) -> 'ACPMemo': except Exception as e: raise ACPApiError(f"Failed to get memo by ID: {e}") + + def get_agent(self, wallet_address: str) -> Optional[IACPAgent]: + url = f"{self.acp_api_url}/agents?filters[walletAddress]={wallet_address}" + + try: + response = requests.get(url) + response.raise_for_status() + data = response.json() + + agents_data = data.get("data", []) + if not agents_data: + return None + + agent_data = agents_data[0] + + offerings = [ + ACPJobOffering( + acp_client=self, + provider_address=agent_data.get("walletAddress"), + type=off["name"], + agent_twitter_handle=agent_data.get("twitterHandle"), + price=off["price"], + requirementSchema=off.get("requirementSchema", None) + ) + for off in agent_data.get("offerings", []) + ] + + return IACPAgent( + id=agent_data["id"], + name=agent_data.get("name"), + description=agent_data.get("description"), + wallet_address=Web3.to_checksum_address(agent_data.get("walletAddress")), + offerings=offerings, + twitter_handle=agent_data.get("twitterHandle") + ) + + except requests.exceptions.RequestException as e: + raise ACPApiError(f"Failed to get agent: {e}") + except Exception as e: + raise ACPError(f"An unexpected error occurred while getting agent: {e}") # Rebuild the AcpJob model after VirtualsACP is defined ACPJob.model_rebuild() diff --git a/virtuals_acp/env.py b/virtuals_acp/env.py index 0bb9d1f..34bf7ab 100644 --- a/virtuals_acp/env.py +++ b/virtuals_acp/env.py @@ -3,16 +3,20 @@ from pydantic import field_validator class EnvSettings(BaseSettings): - WHITELISTED_WALLET_PRIVATE_KEY: Optional[str] = None + BUYER_WALLET_PRIVATE_KEY: Optional[str] = None + SELLER_WALLET_PRIVATE_KEY: Optional[str] = None + EVALUATOR_WALLET_PRIVATE_KEY: Optional[str] = None BUYER_AGENT_WALLET_ADDRESS: Optional[str] = None SELLER_AGENT_WALLET_ADDRESS: Optional[str] = None EVALUATOR_AGENT_WALLET_ADDRESS: Optional[str] = None - - @field_validator("WHITELISTED_WALLET_PRIVATE_KEY") + BUYER_GAME_TWITTER_ACCESS_TOKEN: Optional[str] = None + SELLER_GAME_TWITTER_ACCESS_TOKEN: Optional[str] = None + EVALUATOR_GAME_TWITTER_ACCESS_TOKEN: Optional[str] = None + @field_validator("BUYER_WALLET_PRIVATE_KEY", "SELLER_WALLET_PRIVATE_KEY", "EVALUATOR_WALLET_PRIVATE_KEY") @classmethod def strip_0x_prefix(cls, v: str) -> str: if v and v.startswith("0x"): - raise ValueError("WHITELISTED_WALLET_PRIVATE_KEY must not start with '0x'. Please remove it.") + raise ValueError("WALLET_PRIVATE_KEY must not start with '0x'. Please remove it.") return v @field_validator("BUYER_AGENT_WALLET_ADDRESS", "SELLER_AGENT_WALLET_ADDRESS", "EVALUATOR_AGENT_WALLET_ADDRESS") diff --git a/virtuals_acp/job.py b/virtuals_acp/job.py index edefad5..8311a62 100644 --- a/virtuals_acp/job.py +++ b/virtuals_acp/job.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field, ConfigDict from virtuals_acp.memo import ACPMemo -from virtuals_acp.models import ACPJobPhase +from virtuals_acp.models import ACPJobPhase, IACPAgent if TYPE_CHECKING: from virtuals_acp.client import VirtualsACP @@ -10,6 +10,8 @@ class ACPJob(BaseModel): id: int provider_address: str + client_address: str + evaluator_address: str price: float acp_client: "VirtualsACP" memos: List[ACPMemo] = Field(default_factory=list) @@ -26,6 +28,39 @@ def __str__(self): f" phase={self.phase}\n" f")" ) + + @property + def service_requirement(self) -> Optional[str]: + """Get the service requirement from the negotiation memo""" + memo = next( + (m for m in self.memos if ACPJobPhase(m.next_phase) == ACPJobPhase.NEGOTIATION), + None + ) + return memo.content if memo else None + + @property + def deliverable(self) -> Optional[str]: + """Get the deliverable from the completed memo""" + memo = next( + (m for m in self.memos if ACPJobPhase(m.next_phase) == ACPJobPhase.COMPLETED), + None + ) + return memo.content if memo else None + + @property + def provider_agent(self) -> Optional["IACPAgent"]: + """Get the provider agent details""" + return self.acp_client.get_agent(self.provider_address) + + @property + def client_agent(self) -> Optional["IACPAgent"]: + """Get the client agent details""" + return self.acp_client.get_agent(self.client_address) + + @property + def evaluator_agent(self) -> Optional["IACPAgent"]: + """Get the evaluator agent details""" + return self.acp_client.get_agent(self.client_address) def pay(self, amount: int, reason: Optional[str] = None): memo = next( @@ -53,7 +88,9 @@ def respond(self, accept: bool, reason: Optional[str] = None): if not reason: reason = f"Job {self.id} {'accepted' if accept else 'rejected'}." - return self.acp_client.respond_to_job_memo(self.id, memo.id, accept, reason) + client_twitter_handle = self.client_agent.twitter_handle + + return self.acp_client.respond_to_job_memo(self.id, memo.id, accept, reason, client_twitter_handle) def deliver(self, deliverable: str): memo = next( diff --git a/virtuals_acp/offering.py b/virtuals_acp/offering.py index 69ffd93..c3c055f 100644 --- a/virtuals_acp/offering.py +++ b/virtuals_acp/offering.py @@ -12,6 +12,7 @@ class ACPJobOffering(BaseModel): provider_address: str type: str price: float + agent_twitter_handle: Optional[str] = None requirementSchema: Optional[Dict[str, Any]] = None model_config = ConfigDict(arbitrary_types_allowed=True) @@ -42,7 +43,7 @@ def initiate_job( self, service_requirement: Union[Dict[str, Any], str], evaluator_address: Optional[str] = None, - expired_at: Optional[datetime] = None + expired_at: Optional[datetime] = None, ) -> int: # Default expiry: 1 day from now if expired_at is None: @@ -60,10 +61,12 @@ def initiate_job( except ValidationError as e: raise ValueError(f"Invalid service requirement: {str(e)}") + return self.acp_client.initiate_job( provider_address=self.provider_address, service_requirement=service_requirement, evaluator_address=evaluator_address, amount=self.price, expired_at=expired_at, + twitter_handle=self.agent_twitter_handle ) \ No newline at end of file