notte-integrations 0.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- notte_integrations-0.0.dev0/.gitignore +179 -0
- notte_integrations-0.0.dev0/PKG-INFO +17 -0
- notte_integrations-0.0.dev0/README.md +0 -0
- notte_integrations-0.0.dev0/pyproject.toml +33 -0
- notte_integrations-0.0.dev0/src/notte_integrations/__init__.py +3 -0
- notte_integrations-0.0.dev0/src/notte_integrations/api/fastapi.py +34 -0
- notte_integrations-0.0.dev0/src/notte_integrations/credentials/README.md +37 -0
- notte_integrations-0.0.dev0/src/notte_integrations/credentials/__init__.py +0 -0
- notte_integrations-0.0.dev0/src/notte_integrations/credentials/hashicorp/__init__.py +0 -0
- notte_integrations-0.0.dev0/src/notte_integrations/credentials/hashicorp/docker-compose.yml +15 -0
- notte_integrations-0.0.dev0/src/notte_integrations/credentials/hashicorp/vault.py +174 -0
- notte_integrations-0.0.dev0/src/notte_integrations/notifiers/README.md +129 -0
- notte_integrations-0.0.dev0/src/notte_integrations/notifiers/__init__.py +0 -0
- notte_integrations-0.0.dev0/src/notte_integrations/notifiers/discord.py +41 -0
- notte_integrations-0.0.dev0/src/notte_integrations/notifiers/mail.py +67 -0
- notte_integrations-0.0.dev0/src/notte_integrations/notifiers/slack.py +25 -0
- notte_integrations-0.0.dev0/src/notte_integrations/py.typed +0 -0
- notte_integrations-0.0.dev0/src/notte_integrations/sessions/__init__.py +0 -0
- notte_integrations-0.0.dev0/src/notte_integrations/sessions/anchor.py +57 -0
- notte_integrations-0.0.dev0/src/notte_integrations/sessions/browserbase.py +82 -0
- notte_integrations-0.0.dev0/src/notte_integrations/sessions/cdp_session.py +77 -0
- notte_integrations-0.0.dev0/src/notte_integrations/sessions/steel.py +53 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
|
|
110
|
+
# pdm
|
|
111
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
112
|
+
#pdm.lock
|
|
113
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
114
|
+
# in version control.
|
|
115
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
116
|
+
.pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
121
|
+
__pypackages__/
|
|
122
|
+
|
|
123
|
+
# Celery stuff
|
|
124
|
+
celerybeat-schedule
|
|
125
|
+
celerybeat.pid
|
|
126
|
+
|
|
127
|
+
# SageMath parsed files
|
|
128
|
+
*.sage.py
|
|
129
|
+
|
|
130
|
+
# Environments
|
|
131
|
+
.env
|
|
132
|
+
.venv
|
|
133
|
+
env/
|
|
134
|
+
venv/
|
|
135
|
+
ENV/
|
|
136
|
+
env.bak/
|
|
137
|
+
venv.bak/
|
|
138
|
+
|
|
139
|
+
# Spyder project settings
|
|
140
|
+
.spyderproject
|
|
141
|
+
.spyproject
|
|
142
|
+
|
|
143
|
+
# Rope project settings
|
|
144
|
+
.ropeproject
|
|
145
|
+
|
|
146
|
+
# mkdocs documentation
|
|
147
|
+
/site
|
|
148
|
+
|
|
149
|
+
# mypy
|
|
150
|
+
.mypy_cache/
|
|
151
|
+
.dmypy.json
|
|
152
|
+
dmypy.json
|
|
153
|
+
|
|
154
|
+
# Pyre type checker
|
|
155
|
+
.pyre/
|
|
156
|
+
|
|
157
|
+
# pytype static type analyzer
|
|
158
|
+
.pytype/
|
|
159
|
+
|
|
160
|
+
# Cython debug symbols
|
|
161
|
+
cython_debug/
|
|
162
|
+
|
|
163
|
+
# PyCharm
|
|
164
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
165
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
166
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
167
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
168
|
+
#.idea/
|
|
169
|
+
|
|
170
|
+
ignore.*
|
|
171
|
+
llm_usage.jsonl
|
|
172
|
+
llm_parsing_error.jsonl
|
|
173
|
+
traces/
|
|
174
|
+
|
|
175
|
+
**/__pycache__/**
|
|
176
|
+
.DS_Store
|
|
177
|
+
**/.DS_Store
|
|
178
|
+
old
|
|
179
|
+
notebook
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: notte-integrations
|
|
3
|
+
Version: 0.0.dev0
|
|
4
|
+
Summary: The integrations for Notte
|
|
5
|
+
Author-email: Notte Team <hello@notte.cc>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: browser-use>=0.1.40
|
|
8
|
+
Requires-Dist: browserbase>=1.2.0
|
|
9
|
+
Requires-Dist: discord-py<2.5.0,>=2.3.0
|
|
10
|
+
Requires-Dist: fastapi>=0.115.8
|
|
11
|
+
Requires-Dist: hvac>=2.3.0
|
|
12
|
+
Requires-Dist: langchain-google-genai>=2.1.1
|
|
13
|
+
Requires-Dist: notte-agent==0.0.dev
|
|
14
|
+
Requires-Dist: notte-browser==0.0.dev
|
|
15
|
+
Requires-Dist: notte-core==0.0.dev
|
|
16
|
+
Requires-Dist: slack-sdk>=3.34.0
|
|
17
|
+
Requires-Dist: uvicorn>=0.29.0
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "notte-integrations"
|
|
3
|
+
version = "0.0.dev"
|
|
4
|
+
description = "The integrations for Notte"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Notte Team ", email = "hello@notte.cc" }
|
|
8
|
+
]
|
|
9
|
+
packages = [
|
|
10
|
+
{ include = "notte_integrations", from = "src" },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
requires-python = ">=3.11"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"notte_core==0.0.dev",
|
|
17
|
+
"notte_browser==0.0.dev",
|
|
18
|
+
"notte_agent==0.0.dev",
|
|
19
|
+
"discord-py>=2.3.0,<2.5.0",
|
|
20
|
+
"fastapi>=0.115.8",
|
|
21
|
+
"uvicorn>=0.29.0",
|
|
22
|
+
"hvac>=2.3.0",
|
|
23
|
+
"browserbase>=1.2.0",
|
|
24
|
+
"browser-use>=0.1.40",
|
|
25
|
+
"langchain-google-genai>=2.1.1",
|
|
26
|
+
"slack-sdk>=3.34.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from notte_agent.common.base import BaseAgent
|
|
7
|
+
from notte_agent.common.types import AgentResponse
|
|
8
|
+
from notte_sdk.types import AgentRunRequest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_agent_router(agent: BaseAgent, prefix: str = "agent") -> APIRouter:
|
|
12
|
+
"""
|
|
13
|
+
Creates a FastAPI router that serves the given agent.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
agent: The BaseAgent implementation to serve
|
|
17
|
+
prefix: Optional URL prefix for the API endpoints
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
APIRouter instance with agent endpoints
|
|
21
|
+
"""
|
|
22
|
+
router = APIRouter(
|
|
23
|
+
prefix=prefix,
|
|
24
|
+
tags=[agent.__class__.__name__],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@router.post("/run", response_model=AgentResponse)
|
|
28
|
+
async def run_agent(request: Annotated[AgentRunRequest, "Agent request parameters"]) -> AgentResponse: # pyright: ignore[reportUnusedFunction]
|
|
29
|
+
try:
|
|
30
|
+
return await agent.run(task=request.task, url=request.url)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
33
|
+
|
|
34
|
+
return router
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
## Basic example code
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from notte_agent.main import Agent
|
|
5
|
+
from notte_integrations.credentials.hashicorp.vault import HashiCorpVault
|
|
6
|
+
import os
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# CRITICAL: make sure to start the vault server before running the agent
|
|
11
|
+
# > cd packages/notte-integrations/src/notte_integrations/credentials/hashicorp
|
|
12
|
+
# > docker-compose --env-file ../../../../../.env up
|
|
13
|
+
|
|
14
|
+
# Then, set the VAULT_URL environment variable either to .env file or
|
|
15
|
+
|
|
16
|
+
# VAULT_URL=http://0.0.0.0:8200
|
|
17
|
+
# VAULT_DEV_ROOT_TOKEN_ID=<your-vault-dev-root-token-id>
|
|
18
|
+
|
|
19
|
+
# Load environment variables
|
|
20
|
+
load_dotenv()
|
|
21
|
+
|
|
22
|
+
# Initialize vault with environment variables
|
|
23
|
+
vault = HashiCorpVault(
|
|
24
|
+
url=os.getenv("VAULT_URL"),
|
|
25
|
+
token=os.getenv("VAULT_DEV_ROOT_TOKEN_ID")
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Add credentials from environment variables
|
|
29
|
+
vault.add_credentials(
|
|
30
|
+
url="https://x.com",
|
|
31
|
+
username=os.getenv("TWITTER_USERNAME"),
|
|
32
|
+
password=os.getenv("TWITTER_PASSWORD")
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Configure and initialize agent with vault
|
|
36
|
+
agent = Agent(vault=vault)
|
|
37
|
+
```
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
version: '3.8'
|
|
2
|
+
services:
|
|
3
|
+
vault:
|
|
4
|
+
image: hashicorp/vault:latest
|
|
5
|
+
container_name: vault
|
|
6
|
+
ports:
|
|
7
|
+
- "8200:8200"
|
|
8
|
+
environment:
|
|
9
|
+
- VAULT_DEV_ROOT_TOKEN_ID=${VAULT_DEV_ROOT_TOKEN_ID}
|
|
10
|
+
- VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200
|
|
11
|
+
cap_add:
|
|
12
|
+
- IPC_LOCK
|
|
13
|
+
command: server -dev
|
|
14
|
+
|
|
15
|
+
# run it using docker-compose --env-file /path/to/.env up
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Protocol, final
|
|
4
|
+
|
|
5
|
+
from hvac.exceptions import InvalidPath
|
|
6
|
+
from notte_core.credentials.base import (
|
|
7
|
+
BaseVault,
|
|
8
|
+
CredentialField,
|
|
9
|
+
VaultCredentials,
|
|
10
|
+
)
|
|
11
|
+
from notte_core.utils.url import get_root_domain
|
|
12
|
+
from typing_extensions import override
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SysProtocol(Protocol):
|
|
16
|
+
def list_mounted_secrets_engines(self) -> Any: ...
|
|
17
|
+
def enable_secrets_engine(self, backend_type: str, path: str, options: dict[str, Any]) -> Any: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SecretsProtocol(Protocol):
|
|
21
|
+
def read_secret_version(self, path: str, mount_point: str) -> Any: ...
|
|
22
|
+
def create_or_update_secret(self, path: str, secret: dict[str, Any], mount_point: str) -> Any: ...
|
|
23
|
+
def delete_metadata_and_all_versions(self, path: str, mount_point: str) -> Any: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class HashiCorpVaultClientProtocol:
|
|
28
|
+
url: str
|
|
29
|
+
token: str
|
|
30
|
+
sys: SysProtocol
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from hvac import Client as HashiCorpVaultClient
|
|
35
|
+
|
|
36
|
+
VAULT_AVAILABLE = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
VAULT_AVAILABLE = False # type: ignore
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_vault_imports():
|
|
42
|
+
if not VAULT_AVAILABLE:
|
|
43
|
+
raise ImportError(
|
|
44
|
+
(
|
|
45
|
+
"The 'hvac' package is required for HashiCorp Vault integration."
|
|
46
|
+
" Install 'vault' optional dependencies with 'uv sync --extra vault'"
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@final
|
|
52
|
+
class HashiCorpVault(BaseVault):
|
|
53
|
+
"""HashiCorp Vault implementation of the BaseVault interface."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, url: str, token: str):
|
|
56
|
+
check_vault_imports()
|
|
57
|
+
self.client: HashiCorpVaultClientProtocol = HashiCorpVaultClient(url=url, token=token) # type: ignore
|
|
58
|
+
self.secrets: SecretsProtocol = self.client.secrets.kv.v2 # type: ignore
|
|
59
|
+
self._mount_path: str = "secret"
|
|
60
|
+
self._init_vault()
|
|
61
|
+
|
|
62
|
+
def _init_vault(self) -> None:
|
|
63
|
+
try:
|
|
64
|
+
mounts = self.client.sys.list_mounted_secrets_engines()
|
|
65
|
+
if self._mount_path not in mounts["data"]:
|
|
66
|
+
self.client.sys.enable_secrets_engine(
|
|
67
|
+
backend_type="kv", path=self._mount_path, options={"version": "2"}
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
mount_info = mounts["data"][f"{self._mount_path}/"]["options"]
|
|
71
|
+
if mount_info.get("version") != "2":
|
|
72
|
+
raise ValueError(f"Existing {self._mount_path} mount is not a KV v2 secrets engine")
|
|
73
|
+
except Exception as e:
|
|
74
|
+
if "path is already in use" not in str(e):
|
|
75
|
+
raise e
|
|
76
|
+
|
|
77
|
+
@override
|
|
78
|
+
def _set_singleton_credentials(self, creds: list[CredentialField]) -> None:
|
|
79
|
+
for cred in creds:
|
|
80
|
+
if not cred.singleton:
|
|
81
|
+
raise ValueError(f"{cred.__class__} can't be set as singleton credential: url-specific only")
|
|
82
|
+
|
|
83
|
+
self.secrets.create_or_update_secret(
|
|
84
|
+
path="singleton_credentials",
|
|
85
|
+
secret=dict(
|
|
86
|
+
**{cred.__class__.__qualname__: cred.value for cred in creds},
|
|
87
|
+
),
|
|
88
|
+
mount_point=self._mount_path,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
def get_singleton_credentials(self) -> list[CredentialField]:
|
|
93
|
+
try:
|
|
94
|
+
secret = self.secrets.read_secret_version(path="singleton_credentials", mount_point=self._mount_path)
|
|
95
|
+
except InvalidPath:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
data = secret["data"]["data"]
|
|
99
|
+
|
|
100
|
+
return [CredentialField.registry[key](value=value) for key, value in data.items()]
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
def _add_credentials(self, creds: VaultCredentials) -> None:
|
|
104
|
+
for cred in creds.creds:
|
|
105
|
+
if cred.singleton:
|
|
106
|
+
raise ValueError(f"{cred.__class__} can't be set as url specific credential: singleton only")
|
|
107
|
+
domain = get_root_domain(creds.url)
|
|
108
|
+
self.secrets.create_or_update_secret(
|
|
109
|
+
path=f"credentials/{domain}",
|
|
110
|
+
secret=dict(
|
|
111
|
+
url=creds.url,
|
|
112
|
+
**{cred.__class__.__qualname__: cred.value for cred in creds.creds},
|
|
113
|
+
),
|
|
114
|
+
mount_point=self._mount_path,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@override
|
|
118
|
+
def _get_credentials_impl(self, url: str) -> VaultCredentials | None:
|
|
119
|
+
domain = get_root_domain(url)
|
|
120
|
+
try:
|
|
121
|
+
secret = self.secrets.read_secret_version(path=f"credentials/{domain}", mount_point=self._mount_path)
|
|
122
|
+
data = secret["data"]["data"]
|
|
123
|
+
url = data["url"]
|
|
124
|
+
del data["url"]
|
|
125
|
+
|
|
126
|
+
return VaultCredentials(
|
|
127
|
+
url=url,
|
|
128
|
+
creds=[CredentialField.registry[key](value=value) for key, value in data.items()],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
except Exception:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
@override
|
|
135
|
+
def remove_credentials(self, url: str) -> None:
|
|
136
|
+
domain = get_root_domain(url)
|
|
137
|
+
self.secrets.delete_metadata_and_all_versions(path=f"credentials/{domain}", mount_point=self._mount_path)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def create_from_env(cls) -> "HashiCorpVault":
|
|
141
|
+
"""Create a HashiCorpVault instance from environment variables.
|
|
142
|
+
|
|
143
|
+
Requires VAULT_URL and VAULT_DEV_ROOT_TOKEN_ID to be set in environment variables.
|
|
144
|
+
Automatically loads from .env file if present.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
HashiCorpVault: Initialized vault instance
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ValueError: If required environment variables are missing or vault server is not running
|
|
151
|
+
"""
|
|
152
|
+
vault_url = os.getenv("VAULT_URL")
|
|
153
|
+
vault_token = os.getenv("VAULT_DEV_ROOT_TOKEN_ID")
|
|
154
|
+
|
|
155
|
+
if not vault_url or not vault_token:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
""""
|
|
158
|
+
VAULT_URL and VAULT_DEV_ROOT_TOKEN_ID must be set in the .env file.
|
|
159
|
+
For example if you are running the vault locally:
|
|
160
|
+
VAULT_URL=http://0.0.0.0:8200
|
|
161
|
+
VAULT_DEV_ROOT_TOKEN_ID=<your-vault-dev-root-token-id>
|
|
162
|
+
"""
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
return cls(url=vault_url, token=vault_token)
|
|
167
|
+
except ConnectionError as e:
|
|
168
|
+
vault_not_running_instructions = """
|
|
169
|
+
Make sure to start the vault server before running the agent.
|
|
170
|
+
Instructions to start the vault server:
|
|
171
|
+
> cd packages/notte-integrations/src/notte_integrations/credentials/hashicorp
|
|
172
|
+
> docker-compose --env-file ../../../../../.env up
|
|
173
|
+
"""
|
|
174
|
+
raise ValueError(f"Vault server is not running. {vault_not_running_instructions}") from e
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Notte Notifiers
|
|
2
|
+
|
|
3
|
+
This guide explains how to set up notification services for Notte using Discord and Slack integrations.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
- [Overview](#overview)
|
|
7
|
+
- [Slack Integration](#slack-integration)
|
|
8
|
+
- [Creating a Slack App](#creating-a-slack-app)
|
|
9
|
+
- [Getting the Required Credentials](#getting-the-slack-credentials)
|
|
10
|
+
- [Configuration](#slack-configuration)
|
|
11
|
+
- [Discord Integration](#discord-integration)
|
|
12
|
+
- [Creating a Discord Bot](#creating-a-discord-bot)
|
|
13
|
+
- [Getting the Required Credentials](#getting-the-discord-credentials)
|
|
14
|
+
- [Configuration](#discord-configuration)
|
|
15
|
+
- [Using the Notifiers](#using-the-notifiers)
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
Notte provides notification services that can send task results to both Slack and Discord. Each integration requires specific credentials that you'll need to obtain from their respective platforms.
|
|
20
|
+
|
|
21
|
+
## Slack Integration
|
|
22
|
+
|
|
23
|
+
### Creating a Slack App
|
|
24
|
+
|
|
25
|
+
1. Go to [Slack API](https://api.slack.com/apps)
|
|
26
|
+
2. Click "Create New App"
|
|
27
|
+
3. Select "From scratch"
|
|
28
|
+
4. Enter a name for your app (e.g., "Notte Notifier")
|
|
29
|
+
5. Select the workspace where you want to install the app
|
|
30
|
+
6. Click "Create App"
|
|
31
|
+
|
|
32
|
+
### Getting the Slack Credentials
|
|
33
|
+
|
|
34
|
+
1. In your app's settings page, navigate to "OAuth & Permissions" in the sidebar
|
|
35
|
+
2. Scroll down to "Scopes" and add the following Bot Token Scopes:
|
|
36
|
+
- `chat:write`
|
|
37
|
+
- `chat:write.public`
|
|
38
|
+
3. Scroll back to the top and click "Install to Workspace"
|
|
39
|
+
4. Authorize the app installation
|
|
40
|
+
5. After installation, you'll see a "Bot User OAuth Token" that starts with `xoxb-` - copy this token
|
|
41
|
+
|
|
42
|
+
To get your channel ID:
|
|
43
|
+
1. Open Slack in your browser
|
|
44
|
+
2. Navigate to the channel where you want to send notifications
|
|
45
|
+
3. The channel ID is in the URL: `https://app.slack.com/client/TXXXXXXXX/CXXXXXXXXX`
|
|
46
|
+
(The `CXXXXXXXXX` part is your channel ID)
|
|
47
|
+
|
|
48
|
+
### Slack Configuration
|
|
49
|
+
|
|
50
|
+
Use the following code to initialize the Slack notifier:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from notte_core.notifiers.slack import SlackConfig, SlackNotifier
|
|
54
|
+
|
|
55
|
+
slack_config = SlackConfig(
|
|
56
|
+
token="xoxb-your-token-here",
|
|
57
|
+
channel_id="C0123456789"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
slack_notifier = SlackNotifier(slack_config)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Discord Integration
|
|
64
|
+
|
|
65
|
+
### Creating a Discord Bot
|
|
66
|
+
|
|
67
|
+
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
|
68
|
+
2. Click "New Application"
|
|
69
|
+
3. Enter a name for your app (e.g., "Notte Notifier")
|
|
70
|
+
4. Navigate to the "Bot" tab in the left sidebar
|
|
71
|
+
5. Click "Add Bot"
|
|
72
|
+
6. Under the "Privileged Gateway Intents" section, enable "Message Content Intent"
|
|
73
|
+
|
|
74
|
+
### Getting the Discord Credentials
|
|
75
|
+
|
|
76
|
+
To get your bot token:
|
|
77
|
+
1. In the "Bot" tab, click "Reset Token" to generate a new token
|
|
78
|
+
2. Copy the token - this is your bot token
|
|
79
|
+
|
|
80
|
+
To invite the bot to your server:
|
|
81
|
+
1. Go to the "OAuth2" tab and then "URL Generator"
|
|
82
|
+
2. Select the following scopes:
|
|
83
|
+
- `bot`
|
|
84
|
+
3. Select the following bot permissions:
|
|
85
|
+
- "Send Messages"
|
|
86
|
+
- "Read Message History"
|
|
87
|
+
4. Copy the generated URL and open it in your browser
|
|
88
|
+
5. Select the server you want to add the bot to and authorize
|
|
89
|
+
|
|
90
|
+
To get your channel ID:
|
|
91
|
+
1. In Discord, enable Developer Mode (User Settings > Advanced > Developer Mode)
|
|
92
|
+
2. Right-click on the channel you want to send messages to
|
|
93
|
+
3. Select "Copy ID"
|
|
94
|
+
|
|
95
|
+
### Discord Configuration
|
|
96
|
+
|
|
97
|
+
Use the following code to initialize the Discord notifier:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from notte_core.notifiers.discord import DiscordConfig, DiscordNotifier
|
|
101
|
+
|
|
102
|
+
discord_config = DiscordConfig(
|
|
103
|
+
token="your-discord-bot-token-here",
|
|
104
|
+
channel_id=123456789012345678 # This should be an integer, not a string
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
discord_notifier = DiscordNotifier(discord_config)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Using the Notifiers
|
|
111
|
+
|
|
112
|
+
Once configured, you can use the notifiers to send notifications:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from notte_core.common.agent.types import AgentResponse
|
|
116
|
+
|
|
117
|
+
# Create a sample response
|
|
118
|
+
response = AgentResponse(answer="This is a test notification")
|
|
119
|
+
|
|
120
|
+
# Send notifications
|
|
121
|
+
await slack_notifier.notify("Test Task", response)
|
|
122
|
+
await discord_notifier.notify("Test Task", response)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The notifications will be formatted according to each platform's markdown styles:
|
|
126
|
+
- Slack uses `*text*` for bold
|
|
127
|
+
- Discord uses `**text**` for bold
|
|
128
|
+
|
|
129
|
+
Remember that both notifiers require async functions, so they must be called within an async context.
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import discord
|
|
4
|
+
from notte_agent.common.notifier import BaseNotifier
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from typing_extensions import override
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DiscordConfig(BaseModel):
|
|
10
|
+
"""Configuration for Discord sending functionality."""
|
|
11
|
+
|
|
12
|
+
token: str
|
|
13
|
+
channel_id: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DiscordNotifier(BaseNotifier):
|
|
17
|
+
"""Discord notification implementation."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: DiscordConfig) -> None:
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.config: DiscordConfig = config
|
|
22
|
+
self._client: discord.Client = discord.Client(intents=discord.Intents.default())
|
|
23
|
+
|
|
24
|
+
@override
|
|
25
|
+
async def send_message(self, text: str) -> None:
|
|
26
|
+
"""Send a message to the configured Discord channel."""
|
|
27
|
+
try:
|
|
28
|
+
|
|
29
|
+
@self._client.event
|
|
30
|
+
async def on_ready(): # pyright: ignore[reportUnusedFunction]
|
|
31
|
+
try:
|
|
32
|
+
channel = self._client.get_channel(self.config.channel_id)
|
|
33
|
+
if channel is None:
|
|
34
|
+
raise ValueError(f"Could not find channel with ID: {self.config.channel_id}")
|
|
35
|
+
_: Any = await channel.send(text) # pyright: ignore[reportUnknownMemberType,reportAttributeAccessIssue]
|
|
36
|
+
finally:
|
|
37
|
+
await self._client.close()
|
|
38
|
+
|
|
39
|
+
await self._client.start(self.config.token)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
raise ValueError(f"Failed to send Discord message: {str(e)}")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import smtplib
|
|
2
|
+
from email.mime.multipart import MIMEMultipart
|
|
3
|
+
from email.mime.text import MIMEText
|
|
4
|
+
|
|
5
|
+
from notte_agent.common.notifier import BaseNotifier
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EmailConfig(BaseModel):
|
|
11
|
+
"""Configuration for email sending functionality."""
|
|
12
|
+
|
|
13
|
+
smtp_server: str
|
|
14
|
+
smtp_port: int = 587
|
|
15
|
+
sender_email: str
|
|
16
|
+
sender_password: str
|
|
17
|
+
receiver_email: str
|
|
18
|
+
subject: str = "Notte Agent Task Report"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EmailNotifier(BaseNotifier):
|
|
22
|
+
"""Email notification implementation."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: EmailConfig) -> None:
|
|
25
|
+
super().__init__() # Call parent class constructor
|
|
26
|
+
self.config: EmailConfig = config
|
|
27
|
+
self._server: smtplib.SMTP | None = None
|
|
28
|
+
|
|
29
|
+
async def connect(self) -> None:
|
|
30
|
+
"""Connect to the SMTP server."""
|
|
31
|
+
if self._server is not None:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
self._server = smtplib.SMTP(host=self.config.smtp_server, port=self.config.smtp_port)
|
|
35
|
+
_ = self._server.starttls()
|
|
36
|
+
_ = self._server.login(user=self.config.sender_email, password=self.config.sender_password)
|
|
37
|
+
|
|
38
|
+
async def disconnect(self) -> None:
|
|
39
|
+
"""Disconnect from the SMTP server."""
|
|
40
|
+
if self._server is not None:
|
|
41
|
+
_ = self._server.quit()
|
|
42
|
+
self._server = None
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
async def send_message(self, text: str) -> None:
|
|
46
|
+
"""Send an email with the given subject and body."""
|
|
47
|
+
await self.connect()
|
|
48
|
+
try:
|
|
49
|
+
if self._server is None:
|
|
50
|
+
await self.connect()
|
|
51
|
+
|
|
52
|
+
msg = MIMEMultipart()
|
|
53
|
+
msg["From"] = self.config.sender_email
|
|
54
|
+
msg["To"] = self.config.receiver_email
|
|
55
|
+
msg["Subject"] = self.config.subject
|
|
56
|
+
|
|
57
|
+
msg.attach(MIMEText(text, "plain"))
|
|
58
|
+
|
|
59
|
+
if self._server:
|
|
60
|
+
_ = self._server.send_message(msg)
|
|
61
|
+
finally:
|
|
62
|
+
await self.disconnect()
|
|
63
|
+
|
|
64
|
+
def __del__(self):
|
|
65
|
+
"""Ensure SMTP connection is closed on deletion."""
|
|
66
|
+
if self._server is not None:
|
|
67
|
+
_ = self._server.quit()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from notte_agent.common.notifier import BaseNotifier
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from slack_sdk.web.client import WebClient
|
|
4
|
+
from typing_extensions import override
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SlackConfig(BaseModel):
|
|
8
|
+
"""Configuration for Slack sending functionality."""
|
|
9
|
+
|
|
10
|
+
token: str
|
|
11
|
+
channel_id: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SlackNotifier(BaseNotifier):
|
|
15
|
+
"""Slack notification implementation."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, config: SlackConfig) -> None:
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.config: SlackConfig = config
|
|
20
|
+
self._client: WebClient = WebClient(token=self.config.token)
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
async def send_message(self, text: str) -> None:
|
|
24
|
+
"""Send a message to the configured Slack channel."""
|
|
25
|
+
_ = self._client.chat_postMessage(channel=self.config.channel_id, text=text) # type: ignore[type_unknown]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from notte_integrations.sessions.cdp_session import CDPSession, CDPSessionsManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_anchor_api_key() -> str:
|
|
13
|
+
anchor_api_key: str | None = os.getenv("ANCHOR_API_KEY")
|
|
14
|
+
if anchor_api_key is None:
|
|
15
|
+
raise ValueError("ANCHOR_API_KEY is not set")
|
|
16
|
+
return anchor_api_key
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AnchorSessionsManager(CDPSessionsManager):
|
|
20
|
+
anchor_base_url: str = "https://api.anchorbrowser.io"
|
|
21
|
+
use_proxy: bool = True
|
|
22
|
+
solve_captcha: bool = True
|
|
23
|
+
anchor_api_key: str = Field(default_factory=get_anchor_api_key)
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
def create_session_cdp(self) -> CDPSession:
|
|
27
|
+
if self.verbose:
|
|
28
|
+
logger.info("Creating Anchor session...")
|
|
29
|
+
|
|
30
|
+
browser_configuration: dict[str, Any] = {}
|
|
31
|
+
|
|
32
|
+
if self.use_proxy:
|
|
33
|
+
browser_configuration["proxy_config"] = {"type": "anchor-residential", "active": True}
|
|
34
|
+
|
|
35
|
+
if self.solve_captcha:
|
|
36
|
+
browser_configuration["captcha_config"] = {"active": True}
|
|
37
|
+
|
|
38
|
+
response = requests.post(
|
|
39
|
+
f"{self.anchor_base_url}/api/sessions",
|
|
40
|
+
headers={
|
|
41
|
+
"anchor-api-key": self.anchor_api_key,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
json=browser_configuration,
|
|
45
|
+
)
|
|
46
|
+
response.raise_for_status()
|
|
47
|
+
session_id: str = response.json()["id"]
|
|
48
|
+
return CDPSession(
|
|
49
|
+
session_id=session_id,
|
|
50
|
+
cdp_url=f"wss://connect.anchorbrowser.io?apiKey={self.anchor_api_key}&sessionId={session_id}",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@override
|
|
54
|
+
def close_session_cdp(self, session_id: str) -> bool:
|
|
55
|
+
if self.verbose:
|
|
56
|
+
logger.info(f"Closing CDP session for URL {session_id}")
|
|
57
|
+
return True
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
from typing_extensions import override
|
|
5
|
+
|
|
6
|
+
from notte_integrations.sessions.cdp_session import CDPSession, CDPSessionsManager
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from browserbase import Browserbase
|
|
10
|
+
except ImportError:
|
|
11
|
+
raise ImportError("Install with notte[browserbase] to include browserbase integration")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# TODO: use api with requests instead of sdk so we don't have the added dependency
|
|
15
|
+
class BrowserBaseSessionsManager(CDPSessionsManager):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
verbose: bool = False,
|
|
19
|
+
stealth: bool = True,
|
|
20
|
+
):
|
|
21
|
+
super().__init__()
|
|
22
|
+
|
|
23
|
+
bb_api_key: str | None = os.getenv("BROWSERBASE_API_KEY")
|
|
24
|
+
bb_project_id: str | None = os.getenv("BROWSERBASE_PROJECT_ID")
|
|
25
|
+
|
|
26
|
+
if bb_api_key is None:
|
|
27
|
+
raise ValueError("BROWSERBASE_API_KEY env variable is not set")
|
|
28
|
+
|
|
29
|
+
if bb_project_id is None:
|
|
30
|
+
raise ValueError("BROWSERBASE_PROJECT_ID env variable is not set")
|
|
31
|
+
|
|
32
|
+
self.bb_api_key: str = bb_api_key
|
|
33
|
+
self.bb_project_id: str = bb_project_id
|
|
34
|
+
|
|
35
|
+
self.bb: Browserbase = Browserbase(api_key=self.bb_api_key)
|
|
36
|
+
self.stealth: bool = stealth
|
|
37
|
+
self.verbose: bool = verbose
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
def create_session_cdp(self) -> CDPSession:
|
|
41
|
+
if self.verbose:
|
|
42
|
+
logger.info("Creating BrowserBase session...")
|
|
43
|
+
|
|
44
|
+
stealth_args = dict(
|
|
45
|
+
browser_settings={
|
|
46
|
+
"fingerprint": {
|
|
47
|
+
"browsers": ["chrome", "firefox", "edge", "safari"],
|
|
48
|
+
"devices": ["mobile", "desktop"],
|
|
49
|
+
"locales": ["en-US", "en-GB"],
|
|
50
|
+
"operatingSystems": ["android", "ios", "linux", "macos", "windows"],
|
|
51
|
+
"screen": {
|
|
52
|
+
"maxHeight": 1080,
|
|
53
|
+
"maxWidth": 1920,
|
|
54
|
+
"minHeight": 1080,
|
|
55
|
+
"minWidth": 1920,
|
|
56
|
+
},
|
|
57
|
+
"viewport": {
|
|
58
|
+
"width": 1920,
|
|
59
|
+
"height": 1080,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
"solveCaptchas": True,
|
|
63
|
+
},
|
|
64
|
+
proxies=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
args = stealth_args if self.stealth else {}
|
|
68
|
+
session = self.bb.sessions.create(project_id=self.bb_project_id, **args) # type: ignore
|
|
69
|
+
|
|
70
|
+
if self.verbose:
|
|
71
|
+
logger.info(f"Got BrowserBase session {session}")
|
|
72
|
+
|
|
73
|
+
return CDPSession(
|
|
74
|
+
session_id=session.id,
|
|
75
|
+
cdp_url=session.connect_url,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@override
|
|
79
|
+
def close_session_cdp(self, session_id: str) -> bool:
|
|
80
|
+
if self.verbose:
|
|
81
|
+
logger.info(f"Closing CDP session for URL {session_id}")
|
|
82
|
+
return True
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
from notte_browser.playwright import WindowManager
|
|
5
|
+
from notte_browser.window import BrowserResource, BrowserWindowOptions
|
|
6
|
+
from notte_sdk.types import BrowserType
|
|
7
|
+
from patchright.async_api import Browser as PatchrightBrowser
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CDPSession(BaseModel):
|
|
13
|
+
session_id: str
|
|
14
|
+
cdp_url: str
|
|
15
|
+
resource: BrowserResource | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CDPSessionsManager(WindowManager, ABC):
|
|
19
|
+
sessions: dict[str, CDPSession] = Field(default_factory=dict)
|
|
20
|
+
last_session: CDPSession | None = Field(default=None)
|
|
21
|
+
browser_type: BrowserType = Field(default=BrowserType.CHROMIUM)
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def create_session_cdp(self) -> CDPSession:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def close_session_cdp(self, session_id: str) -> bool:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
async def create_playwright_browser(self, options: BrowserWindowOptions) -> PatchrightBrowser:
|
|
33
|
+
session = self.create_session_cdp()
|
|
34
|
+
if session.cdp_url in self.sessions:
|
|
35
|
+
raise ValueError(f"Session {session.session_id} already exists")
|
|
36
|
+
|
|
37
|
+
cdp_options = options.set_cdp_url(session.cdp_url)
|
|
38
|
+
logger.info(f"Connecting to CDP at {cdp_options.cdp_url}")
|
|
39
|
+
browser = await self.connect_cdp_browser(cdp_options)
|
|
40
|
+
self.sessions[session.cdp_url] = session
|
|
41
|
+
self.last_session = session
|
|
42
|
+
return browser
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
async def get_browser_resource(self, options: BrowserWindowOptions) -> BrowserResource:
|
|
46
|
+
resource = await super().get_browser_resource(options)
|
|
47
|
+
cdp_url = resource.options.cdp_url
|
|
48
|
+
if cdp_url is None:
|
|
49
|
+
if self.last_session is None:
|
|
50
|
+
raise ValueError(f"CDP URL is not set for resource {cdp_url} and last session is not set")
|
|
51
|
+
logger.info(f"Setting CDP URL for resource {cdp_url} to {self.last_session.cdp_url}")
|
|
52
|
+
resource.options = resource.options.set_cdp_url(self.last_session.cdp_url)
|
|
53
|
+
cdp_url = self.last_session.cdp_url
|
|
54
|
+
if cdp_url not in self.sessions:
|
|
55
|
+
raise ValueError(f"Session {cdp_url} not found")
|
|
56
|
+
self.sessions[cdp_url].resource = resource
|
|
57
|
+
self.browser = None # pyright: ignore[reportUnannotatedClassAttribute]
|
|
58
|
+
self.last_session = None
|
|
59
|
+
return resource
|
|
60
|
+
|
|
61
|
+
@override
|
|
62
|
+
async def release_browser_resource(self, resource: BrowserResource) -> None:
|
|
63
|
+
await super().release_browser_resource(resource)
|
|
64
|
+
cdp_url = resource.options.cdp_url
|
|
65
|
+
if cdp_url not in self.sessions:
|
|
66
|
+
raise ValueError(f"Session {cdp_url} not found")
|
|
67
|
+
session = self.sessions[cdp_url]
|
|
68
|
+
status = self.close_session_cdp(session.session_id)
|
|
69
|
+
if not status:
|
|
70
|
+
logger.error(f"Failed to close session {session.session_id}")
|
|
71
|
+
del self.sessions[cdp_url]
|
|
72
|
+
|
|
73
|
+
@override
|
|
74
|
+
async def stop(self) -> None:
|
|
75
|
+
await super().stop()
|
|
76
|
+
for session in self.sessions.values():
|
|
77
|
+
_ = self.close_session_cdp(session.session_id)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from loguru import logger
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
from typing_extensions import override
|
|
7
|
+
|
|
8
|
+
from notte_integrations.sessions.cdp_session import CDPSession, CDPSessionsManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_steel_api_key() -> str:
|
|
12
|
+
steel_api_key: str | None = os.getenv("STEEL_API_KEY")
|
|
13
|
+
if steel_api_key is None:
|
|
14
|
+
raise ValueError("STEEL_API_KEY is not set")
|
|
15
|
+
return steel_api_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SteelSessionsManager(CDPSessionsManager):
|
|
19
|
+
steel_base_url: str = "api.steel.dev" # localhost:3000"
|
|
20
|
+
steel_api_key: str = Field(default_factory=get_steel_api_key)
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def create_session_cdp(self) -> CDPSession:
|
|
24
|
+
logger.info("Creating Steel session...")
|
|
25
|
+
|
|
26
|
+
url = f"https://{self.steel_base_url}/v1/sessions"
|
|
27
|
+
|
|
28
|
+
headers = {"Steel-Api-Key": self.steel_api_key}
|
|
29
|
+
|
|
30
|
+
response = requests.post(url, headers=headers)
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
data: dict[str, str] = response.json()
|
|
33
|
+
if "localhost" in self.steel_base_url:
|
|
34
|
+
cdp_url = f"ws://{self.steel_base_url}/v1/devtools/browser/{data['id']}"
|
|
35
|
+
else:
|
|
36
|
+
cdp_url = f"wss://connect.steel.dev?apiKey={self.steel_api_key}&sessionId={data['id']}"
|
|
37
|
+
return CDPSession(session_id=data["id"], cdp_url=cdp_url)
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
def close_session_cdp(self, session_id: str) -> bool:
|
|
41
|
+
if self.verbose:
|
|
42
|
+
logger.info(f"Closing CDP session for URL {session_id}")
|
|
43
|
+
|
|
44
|
+
url = f"https://{self.steel_base_url}/v1/sessions/{session_id}/release"
|
|
45
|
+
|
|
46
|
+
headers = {"Steel-Api-Key": self.steel_api_key}
|
|
47
|
+
|
|
48
|
+
response = requests.post(url, headers=headers)
|
|
49
|
+
if response.status_code != 200:
|
|
50
|
+
if self.verbose:
|
|
51
|
+
logger.error(f"Failed to release Steel session {session_id}: {response.json()}")
|
|
52
|
+
return False
|
|
53
|
+
return True
|