chore: import upstream snapshot with attribution
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Integration examples
|
||||
|
||||
This directory is for examples that show Browser Use working with external products, APIs, and services.
|
||||
|
||||
## Where to put integration contributions
|
||||
|
||||
- Use `examples/integrations/<provider>/` for small, runnable examples that demonstrate Browser Use with a specific third-party service.
|
||||
- Use `examples/custom-functions/` for provider-agnostic custom tool patterns.
|
||||
- Use `browser_use/integrations/<provider>/` only when the integration is shipped as part of the Browser Use package and has tests.
|
||||
- Keep product-specific workflows, full applications, or large third-party projects in their own repositories.
|
||||
- Add third-party projects to the community list below instead of vendoring their code into this repository.
|
||||
|
||||
## Example checklist
|
||||
|
||||
- Use `uv` in setup instructions.
|
||||
- Keep the example focused on the Browser Use integration point.
|
||||
- Document required environment variables, OAuth scopes, and local services.
|
||||
- Do not commit secrets, tokens, generated credentials, or private account data.
|
||||
- Prefer `ChatBrowserUse()` unless the example is specifically about another model.
|
||||
- Include the command that runs the example from the repository root.
|
||||
|
||||
## Community integrations
|
||||
|
||||
External projects listed here are maintained outside this repository. A listing is a pointer for users, not a support guarantee from Browser Use maintainers.
|
||||
|
||||
Add entries in this format:
|
||||
|
||||
```markdown
|
||||
- [Project name](https://github.com/org/project) - One sentence about what it integrates with. Maintained by @github-handle.
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
from agentmail import AsyncAgentMail # type: ignore
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from browser_use import Agent, Browser, ChatBrowserUse
|
||||
from examples.integrations.agentmail.email_tools import EmailTools
|
||||
|
||||
TASK = """
|
||||
Go to reddit.com, create a new account (use the get_email_address), make up password and all other information, confirm the 2fa with get_latest_email, and like latest post on r/elon subreddit.
|
||||
"""
|
||||
|
||||
|
||||
async def main():
|
||||
# Create email inbox
|
||||
# Get an API key from https://agentmail.to/
|
||||
email_client = AsyncAgentMail()
|
||||
inbox = await email_client.inboxes.create()
|
||||
print(f'Your email address is: {inbox.inbox_id}\n\n')
|
||||
|
||||
# Initialize the tools for browser-use agent
|
||||
tools = EmailTools(email_client=email_client, inbox=inbox)
|
||||
|
||||
# Initialize the LLM for browser-use agent
|
||||
llm = ChatBrowserUse(model='bu-2-0')
|
||||
|
||||
# Set your local browser path
|
||||
browser = Browser(executable_path='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
|
||||
|
||||
agent = Agent(task=TASK, tools=tools, llm=llm, browser=browser)
|
||||
|
||||
await agent.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Email management to enable 2fa.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
# run `pip install agentmail` to install the library
|
||||
from agentmail import AsyncAgentMail, Message, MessageReceivedEvent, Subscribe # type: ignore
|
||||
from agentmail.inboxes.types.inbox import Inbox # type: ignore
|
||||
from agentmail.inboxes.types.inbox_id import InboxId # type: ignore
|
||||
|
||||
from browser_use import Tools
|
||||
|
||||
# Configure basic logging if not already configured
|
||||
if not logging.getLogger().handlers:
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(name)s - %(message)s')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailTools(Tools):
|
||||
def __init__(
|
||||
self,
|
||||
email_client: AsyncAgentMail | None = None,
|
||||
email_timeout: int = 30,
|
||||
inbox: Inbox | None = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.email_client = email_client or AsyncAgentMail()
|
||||
|
||||
self.email_timeout = email_timeout
|
||||
|
||||
self.register_email_tools()
|
||||
|
||||
self.inbox: Inbox | None = inbox
|
||||
|
||||
def _serialize_message_for_llm(self, message: Message) -> str:
|
||||
"""
|
||||
Serialize a message for the LLM
|
||||
"""
|
||||
# Use text if available, otherwise convert HTML to simple text
|
||||
body_content = message.text
|
||||
if not body_content and message.html:
|
||||
body_content = self._html_to_text(message.html)
|
||||
|
||||
msg = f'From: {message.from_}\nTo: {message.to}\nTimestamp: {message.timestamp.isoformat()}\nSubject: {message.subject}\nBody: {body_content}'
|
||||
return msg
|
||||
|
||||
def _html_to_text(self, html: str) -> str:
|
||||
"""
|
||||
Simple HTML to text conversion
|
||||
"""
|
||||
import re
|
||||
|
||||
# Remove script and style elements - handle spaces in closing tags
|
||||
html = re.sub(r'<script\b[^>]*>.*?</script\s*>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||
html = re.sub(r'<style\b[^>]*>.*?</style\s*>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||
|
||||
# Remove HTML tags
|
||||
html = re.sub(r'<[^>]+>', '', html)
|
||||
|
||||
# Decode HTML entities
|
||||
html = html.replace(' ', ' ')
|
||||
html = html.replace('&', '&')
|
||||
html = html.replace('<', '<')
|
||||
html = html.replace('>', '>')
|
||||
html = html.replace('"', '"')
|
||||
html = html.replace(''', "'")
|
||||
|
||||
# Clean up whitespace
|
||||
html = re.sub(r'\s+', ' ', html)
|
||||
html = html.strip()
|
||||
|
||||
return html
|
||||
|
||||
async def get_or_create_inbox_client(self) -> Inbox:
|
||||
"""
|
||||
Create a default inbox profile for this API key (assume that agent is on free tier)
|
||||
|
||||
If you are not on free tier it is recommended to create 1 inbox per agent.
|
||||
"""
|
||||
if self.inbox:
|
||||
return self.inbox
|
||||
|
||||
return await self.create_inbox_client()
|
||||
|
||||
async def create_inbox_client(self) -> Inbox:
|
||||
"""
|
||||
Create a default inbox profile for this API key (assume that agent is on free tier)
|
||||
|
||||
If you are not on free tier it is recommended to create 1 inbox per agent.
|
||||
"""
|
||||
inbox = await self.email_client.inboxes.create()
|
||||
self.inbox = inbox
|
||||
return inbox
|
||||
|
||||
async def wait_for_message(self, inbox_id: InboxId) -> Message:
|
||||
"""
|
||||
Wait for a message to be received in the inbox
|
||||
"""
|
||||
async with self.email_client.websockets.connect() as ws:
|
||||
await ws.send_subscribe(message=Subscribe(inbox_ids=[inbox_id]))
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await asyncio.wait_for(ws.recv(), timeout=self.email_timeout)
|
||||
if isinstance(data, MessageReceivedEvent):
|
||||
await self.email_client.inboxes.messages.update(
|
||||
inbox_id=inbox_id, message_id=data.message.message_id, remove_labels=['unread']
|
||||
)
|
||||
msg = data.message
|
||||
logger.info(f'Received new message from: {msg.from_} with subject: {msg.subject}')
|
||||
return msg
|
||||
# If not MessageReceived, continue waiting for the next event
|
||||
except TimeoutError:
|
||||
raise TimeoutError(f'No email received in the inbox in {self.email_timeout}s')
|
||||
|
||||
def register_email_tools(self):
|
||||
"""Register all email-related controller actions"""
|
||||
|
||||
@self.action('Get email address for login. You can use this email to login to any service with email and password')
|
||||
async def get_email_address() -> str:
|
||||
"""
|
||||
Get the email address of the inbox
|
||||
"""
|
||||
inbox = await self.get_or_create_inbox_client()
|
||||
logger.info(f'Email address: {inbox.inbox_id}')
|
||||
return inbox.inbox_id
|
||||
|
||||
@self.action(
|
||||
'Get the latest unread email from the inbox from the last max_age_minutes (default 5 minutes). Waits some seconds for new emails if none found. Use for 2FA codes.'
|
||||
)
|
||||
async def get_latest_email(max_age_minutes: int = 5) -> str:
|
||||
"""
|
||||
1. Check for unread emails within the last max_age_minutes
|
||||
2. If no recent unread email, wait 30 seconds for new email via websocket
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
inbox = await self.get_or_create_inbox_client()
|
||||
|
||||
# Get unread emails
|
||||
emails = await self.email_client.inboxes.messages.list(inbox_id=inbox.inbox_id, labels=['unread'])
|
||||
# Filter unread emails by time window - use UTC timezone to match email timestamps
|
||||
time_cutoff = datetime.now(timezone.utc) - timedelta(minutes=max_age_minutes)
|
||||
logger.debug(f'Time cutoff: {time_cutoff}')
|
||||
logger.info(f'Found {len(emails.messages)} unread emails for inbox {inbox.inbox_id}')
|
||||
recent_unread_emails = []
|
||||
|
||||
for i, email_summary in enumerate(emails.messages):
|
||||
# Get full email details to check timestamp
|
||||
full_email = await self.email_client.inboxes.messages.get(
|
||||
inbox_id=inbox.inbox_id, message_id=email_summary.message_id
|
||||
)
|
||||
# Handle timezone comparison properly
|
||||
email_timestamp = full_email.timestamp
|
||||
if email_timestamp.tzinfo is None:
|
||||
# If email timestamp is naive, assume UTC
|
||||
email_timestamp = email_timestamp.replace(tzinfo=timezone.utc)
|
||||
|
||||
if email_timestamp >= time_cutoff:
|
||||
recent_unread_emails.append(full_email)
|
||||
|
||||
# If we have recent unread emails, return the latest one
|
||||
if recent_unread_emails:
|
||||
# Sort by timestamp and get the most recent
|
||||
recent_unread_emails.sort(key=lambda x: x.timestamp, reverse=True)
|
||||
logger.info(f'Found {len(recent_unread_emails)} recent unread emails for inbox {inbox.inbox_id}')
|
||||
|
||||
latest_email = recent_unread_emails[0]
|
||||
|
||||
# Mark as read
|
||||
await self.email_client.inboxes.messages.update(
|
||||
inbox_id=inbox.inbox_id, message_id=latest_email.message_id, remove_labels=['unread']
|
||||
)
|
||||
logger.info(f'Latest email from: {latest_email.from_} with subject: {latest_email.subject}')
|
||||
return self._serialize_message_for_llm(latest_email)
|
||||
else:
|
||||
logger.info('No recent unread emails, waiting for a new one')
|
||||
# No recent unread emails, wait for new one
|
||||
try:
|
||||
latest_message = await self.wait_for_message(inbox_id=inbox.inbox_id)
|
||||
except TimeoutError:
|
||||
return f'No email received in the inbox in {self.email_timeout}s'
|
||||
# logger.info(f'Latest message: {latest_message}')
|
||||
return self._serialize_message_for_llm(latest_message)
|
||||
@@ -0,0 +1,123 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
import discord # type: ignore
|
||||
from discord.ext import commands # type: ignore
|
||||
|
||||
from browser_use.agent.service import Agent
|
||||
from browser_use.browser import BrowserProfile, BrowserSession
|
||||
from browser_use.llm import BaseChatModel
|
||||
|
||||
|
||||
class DiscordBot(commands.Bot):
|
||||
"""Discord bot implementation for Browser-Use tasks.
|
||||
|
||||
This bot allows users to run browser automation tasks through Discord messages.
|
||||
Processes tasks asynchronously and sends the result back to the user in response to the message.
|
||||
Messages must start with the configured prefix (default: "$bu") followed by the task description.
|
||||
|
||||
Args:
|
||||
llm (BaseChatModel): Language model instance to use for task processing
|
||||
prefix (str, optional): Command prefix for triggering browser tasks. Defaults to "$bu"
|
||||
ack (bool, optional): Whether to acknowledge task receipt with a message. Defaults to False
|
||||
browser_profile (BrowserProfile, optional): Browser profile settings.
|
||||
Defaults to headless mode
|
||||
|
||||
Usage:
|
||||
```python
|
||||
from browser_use import ChatOpenAI
|
||||
|
||||
llm = ChatOpenAI()
|
||||
bot = DiscordBot(llm=llm, prefix='$bu', ack=True)
|
||||
bot.run('YOUR_DISCORD_TOKEN')
|
||||
```
|
||||
|
||||
Discord Usage:
|
||||
Send messages starting with the prefix:
|
||||
"$bu search for python tutorials"
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: BaseChatModel,
|
||||
prefix: str = '$bu',
|
||||
ack: bool = False,
|
||||
browser_profile: BrowserProfile = BrowserProfile(headless=True),
|
||||
):
|
||||
self.llm = llm
|
||||
self.prefix = prefix.strip()
|
||||
self.ack = ack
|
||||
self.browser_profile = browser_profile
|
||||
|
||||
# Define intents.
|
||||
intents = discord.Intents.default() # type: ignore
|
||||
intents.message_content = True # Enable message content intent
|
||||
intents.members = True # Enable members intent for user info
|
||||
|
||||
# Initialize the bot with a command prefix and intents.
|
||||
super().__init__(command_prefix='!', intents=intents) # You may not need prefix, just here for flexibility
|
||||
|
||||
# self.tree = app_commands.CommandTree(self) # Initialize command tree for slash commands.
|
||||
|
||||
async def on_ready(self):
|
||||
"""Called when the bot is ready."""
|
||||
try:
|
||||
print(f'We have logged in as {self.user}')
|
||||
cmds = await self.tree.sync() # Sync the command tree with discord
|
||||
|
||||
except Exception as e:
|
||||
print(f'Error during bot startup: {e}')
|
||||
|
||||
async def on_message(self, message):
|
||||
"""Called when a message is received."""
|
||||
try:
|
||||
if message.author == self.user: # Ignore the bot's messages
|
||||
return
|
||||
if message.content.strip().startswith(f'{self.prefix} '):
|
||||
if self.ack:
|
||||
try:
|
||||
await message.reply(
|
||||
'Starting browser use task...',
|
||||
mention_author=True, # Don't ping the user
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'Error sending start message: {e}')
|
||||
|
||||
try:
|
||||
agent_message = await self.run_agent(message.content.replace(f'{self.prefix} ', '').strip())
|
||||
await message.channel.send(content=f'{agent_message}', reference=message, mention_author=True)
|
||||
except Exception as e:
|
||||
await message.channel.send(
|
||||
content=f'Error during task execution: {str(e)}',
|
||||
reference=message,
|
||||
mention_author=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f'Error in message handling: {e}')
|
||||
|
||||
# await self.process_commands(message) # Needed to process bot commands
|
||||
|
||||
async def run_agent(self, task: str) -> str:
|
||||
try:
|
||||
browser_session = BrowserSession(browser_profile=self.browser_profile)
|
||||
agent = Agent(task=(task), llm=self.llm, browser_session=browser_session)
|
||||
result = await agent.run()
|
||||
|
||||
agent_message = None
|
||||
if result.is_done():
|
||||
agent_message = result.history[-1].result[0].extracted_content
|
||||
|
||||
if agent_message is None:
|
||||
agent_message = 'Oops! Something went wrong while running Browser-Use.'
|
||||
|
||||
return agent_message
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f'Browser-use task failed: {str(e)}')
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
This examples requires you to have a Discord bot token and the bot already added to a server.
|
||||
|
||||
Five Steps to create and invite a Discord bot:
|
||||
|
||||
1. Create a Discord Application:
|
||||
* Go to the Discord Developer Portal: https://discord.com/developers/applications
|
||||
* Log in to the Discord website.
|
||||
* Click on "New Application".
|
||||
* Give the application a name and click "Create".
|
||||
2. Configure the Bot:
|
||||
* Navigate to the "Bot" tab on the left side of the screen.
|
||||
* Make sure "Public Bot" is ticked if you want others to invite your bot.
|
||||
* Generate your bot token by clicking on "Reset Token", Copy the token and save it securely.
|
||||
* Do not share the bot token. Treat it like a password. If the token is leaked, regenerate it.
|
||||
3. Enable Privileged Intents:
|
||||
* Scroll down to the "Privileged Gateway Intents" section.
|
||||
* Enable the necessary intents (e.g., "Server Members Intent" and "Message Content Intent").
|
||||
--> Note: Enabling privileged intents for bots in over 100 guilds requires bot verification. You may need to contact Discord support to enable privileged intents for verified bots.
|
||||
4. Generate Invite URL:
|
||||
* Go to "OAuth2" tab and "OAuth2 URL Generator" section.
|
||||
* Under "scopes", tick the "bot" checkbox.
|
||||
* Tick the permissions required for your bot to function under “Bot Permissions”.
|
||||
* e.g. "Send Messages", "Send Messages in Threads", "Read Message History", "Mention Everyone".
|
||||
* Copy the generated URL under the "GENERATED URL" section at the bottom.
|
||||
5. Invite the Bot:
|
||||
* Paste the URL into your browser.
|
||||
* Choose a server to invite the bot to.
|
||||
* Click “Authorize”.
|
||||
--> Note: The person adding the bot needs "Manage Server" permissions.
|
||||
6. Run the code below to start the bot with your bot token.
|
||||
7. Write e.g. "/bu what's the weather in Tokyo?" to start a browser-use task and get a response inside the Discord channel.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
from browser_use.browser import BrowserProfile
|
||||
from browser_use.llm import ChatGoogle
|
||||
from examples.integrations.discord.discord_api import DiscordBot
|
||||
|
||||
# load credentials from environment variables
|
||||
bot_token = os.getenv('DISCORD_BOT_TOKEN')
|
||||
if not bot_token:
|
||||
raise ValueError('Discord bot token not found in .env file.')
|
||||
|
||||
api_key = os.getenv('GOOGLE_API_KEY')
|
||||
if not api_key:
|
||||
raise ValueError('GOOGLE_API_KEY is not set')
|
||||
|
||||
llm = ChatGoogle(model='gemini-2.0-flash-exp', api_key=api_key)
|
||||
|
||||
bot = DiscordBot(
|
||||
llm=llm, # required; instance of BaseChatModel
|
||||
prefix='$bu', # optional; prefix of messages to trigger browser-use, defaults to "$bu"
|
||||
ack=True, # optional; whether to acknowledge task receipt with a message, defaults to False
|
||||
browser_profile=BrowserProfile(
|
||||
headless=False
|
||||
), # optional; useful for changing headless mode or other browser configs, defaults to headless mode
|
||||
)
|
||||
|
||||
bot.run(
|
||||
token=bot_token, # required; Discord bot token
|
||||
)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Gmail 2FA Integration Example with Grant Mechanism
|
||||
This example demonstrates how to use the Gmail integration for handling 2FA codes
|
||||
during web automation with a robust credential grant and re-authentication system.
|
||||
|
||||
Features:
|
||||
- Automatic credential validation and setup
|
||||
- Interactive OAuth grant flow when credentials are missing/invalid
|
||||
- Fallback re-authentication mechanisms
|
||||
- Clear error handling and user guidance
|
||||
|
||||
Setup:
|
||||
1. Enable Gmail API in Google Cloud Console
|
||||
2. Create OAuth 2.0 credentials and download JSON
|
||||
3. Save credentials as ~/.config/browseruse/gmail_credentials.json
|
||||
4. Run this example - it will guide you through OAuth setup if needed
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Add the parent directory to the path so we can import browser_use
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from browser_use import Agent, ChatOpenAI, Tools
|
||||
from browser_use.config import CONFIG
|
||||
from browser_use.integrations.gmail import GmailService, register_gmail_actions
|
||||
|
||||
|
||||
class GmailGrantManager:
|
||||
"""
|
||||
Manages Gmail OAuth credential grants and authentication flows.
|
||||
Provides a robust mechanism for setting up and maintaining Gmail API access.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config_dir = CONFIG.BROWSER_USE_CONFIG_DIR
|
||||
self.credentials_file = self.config_dir / 'gmail_credentials.json'
|
||||
self.token_file = self.config_dir / 'gmail_token.json'
|
||||
print(f'GmailGrantManager initialized with config_dir: {self.config_dir}')
|
||||
print(f'GmailGrantManager initialized with credentials_file: {self.credentials_file}')
|
||||
print(f'GmailGrantManager initialized with token_file: {self.token_file}')
|
||||
|
||||
def check_credentials_exist(self) -> bool:
|
||||
"""Check if OAuth credentials file exists."""
|
||||
return self.credentials_file.exists()
|
||||
|
||||
def check_token_exists(self) -> bool:
|
||||
"""Check if saved token file exists."""
|
||||
return self.token_file.exists()
|
||||
|
||||
def validate_credentials_format(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate that the credentials file has the correct format.
|
||||
Returns (is_valid, error_message)
|
||||
"""
|
||||
if not self.check_credentials_exist():
|
||||
return False, 'Credentials file not found'
|
||||
|
||||
try:
|
||||
with open(self.credentials_file) as f:
|
||||
creds = json.load(f)
|
||||
|
||||
# Accept if either 'web' or 'installed' section exists and is not empty
|
||||
if creds.get('web') or creds.get('installed'):
|
||||
return True, 'Credentials file is valid'
|
||||
return False, "Invalid credentials format - neither 'web' nor 'installed' sections found"
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return False, 'Credentials file is not valid JSON'
|
||||
except Exception as e:
|
||||
return False, f'Error reading credentials file: {e}'
|
||||
|
||||
async def setup_oauth_credentials(self) -> bool:
|
||||
"""
|
||||
Guide user through OAuth credentials setup process.
|
||||
Returns True if setup is successful.
|
||||
"""
|
||||
print('\n🔐 Gmail OAuth Credentials Setup Required')
|
||||
print('=' * 50)
|
||||
|
||||
if not self.check_credentials_exist():
|
||||
print('❌ Gmail credentials file not found')
|
||||
else:
|
||||
is_valid, error = self.validate_credentials_format()
|
||||
if not is_valid:
|
||||
print(f'❌ Gmail credentials file is invalid: {error}')
|
||||
|
||||
print('\n📋 To set up Gmail API access:')
|
||||
print('1. Go to https://console.cloud.google.com/')
|
||||
print('2. Create a new project or select an existing one')
|
||||
print('3. Enable the Gmail API:')
|
||||
print(' - Go to "APIs & Services" > "Library"')
|
||||
print(' - Search for "Gmail API" and enable it')
|
||||
print('4. Create OAuth 2.0 credentials:')
|
||||
print(' - Go to "APIs & Services" > "Credentials"')
|
||||
print(' - Click "Create Credentials" > "OAuth client ID"')
|
||||
print(' - Choose "Desktop application"')
|
||||
print(' - Download the JSON file')
|
||||
print(f'5. Save the JSON file as: {self.credentials_file}')
|
||||
print(f'6. Ensure the directory exists: {self.config_dir}')
|
||||
|
||||
# Create config directory if it doesn't exist
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
print(f'\n✅ Created config directory: {self.config_dir}')
|
||||
|
||||
# Wait for user to set up credentials
|
||||
while True:
|
||||
user_input = input('\n❓ Have you saved the credentials file? (y/n/skip): ').lower().strip()
|
||||
|
||||
if user_input == 'skip':
|
||||
print('⏭️ Skipping credential validation for now')
|
||||
return False
|
||||
elif user_input == 'y':
|
||||
if self.check_credentials_exist():
|
||||
is_valid, error = self.validate_credentials_format()
|
||||
if is_valid:
|
||||
print('✅ Credentials file found and validated!')
|
||||
return True
|
||||
else:
|
||||
print(f'❌ Credentials file is invalid: {error}')
|
||||
print('Please check the file format and try again.')
|
||||
else:
|
||||
print(f'❌ Credentials file still not found at: {self.credentials_file}')
|
||||
elif user_input == 'n':
|
||||
print('⏳ Please complete the setup steps above and try again.')
|
||||
else:
|
||||
print('Please enter y, n, or skip')
|
||||
|
||||
async def test_authentication(self, gmail_service: GmailService) -> tuple[bool, str]:
|
||||
"""
|
||||
Test Gmail authentication and return status.
|
||||
Returns (success, message)
|
||||
"""
|
||||
try:
|
||||
print('🔍 Testing Gmail authentication...')
|
||||
success = await gmail_service.authenticate()
|
||||
|
||||
if success and gmail_service.is_authenticated():
|
||||
print('✅ Gmail authentication successful!')
|
||||
return True, 'Authentication successful'
|
||||
else:
|
||||
return False, 'Authentication failed - invalid credentials or OAuth flow failed'
|
||||
|
||||
except Exception as e:
|
||||
return False, f'Authentication error: {e}'
|
||||
|
||||
async def handle_authentication_failure(self, gmail_service: GmailService, error_msg: str) -> bool:
|
||||
"""
|
||||
Handle authentication failures with fallback mechanisms.
|
||||
Returns True if recovery was successful.
|
||||
"""
|
||||
print(f'\n❌ Gmail authentication failed: {error_msg}')
|
||||
print('\n🔧 Attempting recovery...')
|
||||
|
||||
# Option 1: Try removing old token file
|
||||
if self.token_file.exists():
|
||||
print('🗑️ Removing old token file to force re-authentication...')
|
||||
try:
|
||||
self.token_file.unlink()
|
||||
print('✅ Old token file removed')
|
||||
|
||||
# Try authentication again
|
||||
success = await gmail_service.authenticate()
|
||||
if success:
|
||||
print('✅ Re-authentication successful!')
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f'❌ Failed to remove token file: {e}')
|
||||
|
||||
# Option 2: Validate and potentially re-setup credentials
|
||||
is_valid, cred_error = self.validate_credentials_format()
|
||||
if not is_valid:
|
||||
print(f'\n❌ Credentials file issue: {cred_error}')
|
||||
print('🔧 Initiating credential re-setup...')
|
||||
|
||||
return await self.setup_oauth_credentials()
|
||||
|
||||
# Option 3: Provide manual troubleshooting steps
|
||||
print('\n🔧 Manual troubleshooting steps:')
|
||||
print('1. Check that Gmail API is enabled in Google Cloud Console')
|
||||
print('2. Verify OAuth consent screen is configured')
|
||||
print('3. Ensure redirect URIs include http://localhost:8080')
|
||||
print('4. Check if credentials file is for the correct project')
|
||||
print('5. Try regenerating OAuth credentials in Google Cloud Console')
|
||||
|
||||
retry = input('\n❓ Would you like to retry authentication? (y/n): ').lower().strip()
|
||||
if retry == 'y':
|
||||
success = await gmail_service.authenticate()
|
||||
return success
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print('🚀 Gmail 2FA Integration Example with Grant Mechanism')
|
||||
print('=' * 60)
|
||||
|
||||
# Initialize grant manager
|
||||
grant_manager = GmailGrantManager()
|
||||
|
||||
# Step 1: Check and validate credentials
|
||||
print('🔍 Step 1: Validating Gmail credentials...')
|
||||
|
||||
if not grant_manager.check_credentials_exist():
|
||||
print('❌ No Gmail credentials found')
|
||||
setup_success = await grant_manager.setup_oauth_credentials()
|
||||
if not setup_success:
|
||||
print('⏹️ Setup cancelled or failed. Exiting...')
|
||||
return
|
||||
else:
|
||||
is_valid, error = grant_manager.validate_credentials_format()
|
||||
if not is_valid:
|
||||
print(f'❌ Invalid credentials: {error}')
|
||||
setup_success = await grant_manager.setup_oauth_credentials()
|
||||
if not setup_success:
|
||||
print('⏹️ Setup cancelled or failed. Exiting...')
|
||||
return
|
||||
else:
|
||||
print('✅ Gmail credentials file found and validated')
|
||||
|
||||
# Step 2: Initialize Gmail service and test authentication
|
||||
print('\n🔍 Step 2: Testing Gmail authentication...')
|
||||
|
||||
gmail_service = GmailService()
|
||||
auth_success, auth_message = await grant_manager.test_authentication(gmail_service)
|
||||
|
||||
if not auth_success:
|
||||
print(f'❌ Initial authentication failed: {auth_message}')
|
||||
recovery_success = await grant_manager.handle_authentication_failure(gmail_service, auth_message)
|
||||
|
||||
if not recovery_success:
|
||||
print('❌ Failed to recover Gmail authentication. Please check your setup.')
|
||||
return
|
||||
|
||||
# Step 3: Initialize tools with authenticated service
|
||||
print('\n🔍 Step 3: Registering Gmail actions...')
|
||||
|
||||
tools = Tools()
|
||||
register_gmail_actions(tools, gmail_service=gmail_service)
|
||||
|
||||
print('✅ Gmail actions registered with tools')
|
||||
print('Available Gmail actions:')
|
||||
print('- get_recent_emails: Get recent emails with filtering')
|
||||
print()
|
||||
|
||||
# Initialize LLM
|
||||
llm = ChatOpenAI(model='gpt-4.1-mini')
|
||||
|
||||
# Step 4: Test Gmail functionality
|
||||
print('🔍 Step 4: Testing Gmail email retrieval...')
|
||||
|
||||
agent = Agent(task='Get recent emails from Gmail to test the integration is working properly', llm=llm, tools=tools)
|
||||
|
||||
try:
|
||||
history = await agent.run()
|
||||
print('✅ Gmail email retrieval test completed')
|
||||
except Exception as e:
|
||||
print(f'❌ Gmail email retrieval test failed: {e}')
|
||||
# Try one more recovery attempt
|
||||
print('🔧 Attempting final recovery...')
|
||||
recovery_success = await grant_manager.handle_authentication_failure(gmail_service, str(e))
|
||||
if recovery_success:
|
||||
print('✅ Recovery successful, re-running test...')
|
||||
history = await agent.run()
|
||||
else:
|
||||
print('❌ Final recovery failed. Please check your Gmail API setup.')
|
||||
return
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
|
||||
# Step 5: Demonstrate 2FA code finding
|
||||
print('🔍 Step 5: Testing 2FA code detection...')
|
||||
|
||||
agent2 = Agent(
|
||||
task='Search for any 2FA verification codes or OTP codes in recent Gmail emails from the last 30 minutes',
|
||||
llm=llm,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
history2 = await agent2.run()
|
||||
print('✅ 2FA code search completed')
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
|
||||
# Step 6: Simulate complete login flow
|
||||
print('🔍 Step 6: Demonstrating complete 2FA login flow...')
|
||||
|
||||
agent3 = Agent(
|
||||
task="""
|
||||
Demonstrate a complete 2FA-enabled login flow:
|
||||
1. Check for any existing 2FA codes in recent emails
|
||||
2. Explain how the agent would handle a typical login:
|
||||
- Navigate to a login page
|
||||
- Enter credentials
|
||||
- Wait for 2FA prompt
|
||||
- Use get_recent_emails to find the verification code
|
||||
- Extract and enter the 2FA code
|
||||
3. Show what types of emails and codes can be detected
|
||||
""",
|
||||
llm=llm,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
history3 = await agent3.run()
|
||||
print('✅ Complete 2FA flow demonstration completed')
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
print('🎉 Gmail 2FA Integration with Grant Mechanism completed successfully!')
|
||||
print('\n💡 Key features demonstrated:')
|
||||
print('- ✅ Automatic credential validation and setup')
|
||||
print('- ✅ Robust error handling and recovery mechanisms')
|
||||
print('- ✅ Interactive OAuth grant flow')
|
||||
print('- ✅ Token refresh and re-authentication')
|
||||
print('- ✅ 2FA code detection and extraction')
|
||||
print('\n🔧 Grant mechanism benefits:')
|
||||
print('- Handles missing or invalid credentials gracefully')
|
||||
print('- Provides clear setup instructions')
|
||||
print('- Automatically recovers from authentication failures')
|
||||
print('- Validates credential format before use')
|
||||
print('- Offers multiple fallback options')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
# Slack Integration
|
||||
|
||||
Steps to create and configure a Slack bot:
|
||||
|
||||
1. Create a Slack App:
|
||||
* Go to the Slack API: https://api.slack.com/apps
|
||||
* Click on "Create New App".
|
||||
* Choose "From scratch" and give your app a name and select the workspace.
|
||||
* Provide a name and description for your bot (these are required fields).
|
||||
2. Configure the Bot:
|
||||
* Navigate to the "OAuth & Permissions" tab on the left side of the screen.
|
||||
* Under "Scopes", add the necessary bot token scopes (add these "chat:write", "channels:history", "im:history").
|
||||
3. Enable Event Subscriptions:
|
||||
* Navigate to the "Event Subscriptions" tab.
|
||||
* Enable events and add the necessary bot events (add these "message.channels", "message.im").
|
||||
* Add your request URL (you can use ngrok to expose your local server if needed). [See how to set up ngrok](#installing-and-starting-ngrok).
|
||||
* **Note:** The URL provided by ngrok is ephemeral and will change each time ngrok is started. You will need to update the request URL in the bot's settings each time you restart ngrok. [See how to update the request URL](#updating-the-request-url-in-bots-settings).
|
||||
4. Add the bot to your Slack workspace:
|
||||
* Navigate to the "OAuth & Permissions" tab.
|
||||
* Under "OAuth Tokens for Your Workspace", click on "Install App to Workspace".
|
||||
* Follow the prompts to authorize the app and add it to your workspace.
|
||||
5. Set up environment variables:
|
||||
* Obtain the `SLACK_SIGNING_SECRET`:
|
||||
* Go to the Slack API: https://api.slack.com/apps
|
||||
* Select your app.
|
||||
* Navigate to the "Basic Information" tab.
|
||||
* Copy the "Signing Secret".
|
||||
* Obtain the `SLACK_BOT_TOKEN`:
|
||||
* Go to the Slack API: https://api.slack.com/apps
|
||||
* Select your app.
|
||||
* Navigate to the "OAuth & Permissions" tab.
|
||||
* Copy the "Bot User OAuth Token".
|
||||
* Create a `.env` file in the root directory of your project and add the following lines:
|
||||
```env
|
||||
SLACK_SIGNING_SECRET=your-signing-secret
|
||||
SLACK_BOT_TOKEN=your-bot-token
|
||||
```
|
||||
6. Invite the bot to a channel:
|
||||
* Use the `/invite @your-bot-name` command in the Slack channel where you want the bot to be active.
|
||||
7. Run the code in `examples/slack_example.py` to start the bot with your bot token and signing secret.
|
||||
8. Write e.g. "$bu what's the weather in Tokyo?" to start a browser-use task and get a response inside the Slack channel.
|
||||
|
||||
## Installing and Starting ngrok
|
||||
|
||||
To expose your local server to the internet, you can use ngrok. Follow these steps to install and start ngrok:
|
||||
|
||||
1. Download ngrok from the official website: https://ngrok.com/download
|
||||
2. Create a free account and follow the official steps to install ngrok.
|
||||
3. Start ngrok by running the following command in your terminal:
|
||||
```sh
|
||||
ngrok http 3000
|
||||
```
|
||||
Replace `3000` with the port number your local server is running on.
|
||||
|
||||
## Updating the Request URL in Bot's Settings
|
||||
|
||||
If you need to update the request URL (e.g., when the ngrok URL changes), follow these steps:
|
||||
|
||||
1. Go to the Slack API: https://api.slack.com/apps
|
||||
2. Select your app.
|
||||
3. Navigate to the "Event Subscriptions" tab.
|
||||
4. Update the "Request URL" field with the new ngrok URL. The URL should be something like: `https://<ngrok-id>.ngrok-free.app/slack/events`
|
||||
5. Save the changes.
|
||||
|
||||
## Installing Required Packages
|
||||
|
||||
To run this example, you need to install the following packages:
|
||||
|
||||
- `fastapi`
|
||||
- `uvicorn`
|
||||
- `slack_sdk`
|
||||
|
||||
You can install these packages using pip:
|
||||
|
||||
```sh
|
||||
pip install fastapi uvicorn slack_sdk
|
||||
@@ -0,0 +1,130 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Annotated
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from slack_sdk.errors import SlackApiError # type: ignore
|
||||
from slack_sdk.signature import SignatureVerifier # type: ignore
|
||||
from slack_sdk.web.async_client import AsyncWebClient # type: ignore
|
||||
|
||||
from browser_use.agent.service import Agent
|
||||
from browser_use.browser import BrowserProfile, BrowserSession
|
||||
from browser_use.llm import BaseChatModel
|
||||
from browser_use.logging_config import setup_logging
|
||||
|
||||
setup_logging()
|
||||
logger = logging.getLogger('slack')
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class SlackBot:
|
||||
def __init__(
|
||||
self,
|
||||
llm: BaseChatModel,
|
||||
bot_token: str,
|
||||
signing_secret: str,
|
||||
ack: bool = False,
|
||||
browser_profile: BrowserProfile = BrowserProfile(headless=True),
|
||||
):
|
||||
if not bot_token or not signing_secret:
|
||||
raise ValueError('Bot token and signing secret must be provided')
|
||||
|
||||
self.llm = llm
|
||||
self.ack = ack
|
||||
self.browser_profile = browser_profile
|
||||
self.client = AsyncWebClient(token=bot_token)
|
||||
self.signature_verifier = SignatureVerifier(signing_secret)
|
||||
self.processed_events = set()
|
||||
logger.info('SlackBot initialized')
|
||||
|
||||
async def handle_event(self, event, event_id):
|
||||
try:
|
||||
logger.info(f'Received event id: {event_id}')
|
||||
if not event_id:
|
||||
logger.warning('Event ID missing in event data')
|
||||
return
|
||||
|
||||
if event_id in self.processed_events:
|
||||
logger.info(f'Event {event_id} already processed')
|
||||
return
|
||||
self.processed_events.add(event_id)
|
||||
|
||||
if 'subtype' in event and event['subtype'] == 'bot_message':
|
||||
return
|
||||
|
||||
text = event.get('text')
|
||||
user_id = event.get('user')
|
||||
if text and text.startswith('$bu '):
|
||||
task = text[len('$bu ') :].strip()
|
||||
if self.ack:
|
||||
try:
|
||||
await self.send_message(
|
||||
event['channel'], f'<@{user_id}> Starting browser use task...', thread_ts=event.get('ts')
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending start message: {e}')
|
||||
|
||||
try:
|
||||
agent_message = await self.run_agent(task)
|
||||
await self.send_message(event['channel'], f'<@{user_id}> {agent_message}', thread_ts=event.get('ts'))
|
||||
except Exception as e:
|
||||
await self.send_message(event['channel'], f'Error during task execution: {str(e)}', thread_ts=event.get('ts'))
|
||||
except Exception as e:
|
||||
logger.error(f'Error in handle_event: {str(e)}')
|
||||
|
||||
async def run_agent(self, task: str) -> str:
|
||||
try:
|
||||
browser_session = BrowserSession(browser_profile=self.browser_profile)
|
||||
agent = Agent(task=task, llm=self.llm, browser_session=browser_session)
|
||||
result = await agent.run()
|
||||
|
||||
agent_message = None
|
||||
if result.is_done():
|
||||
agent_message = result.history[-1].result[0].extracted_content
|
||||
|
||||
if agent_message is None:
|
||||
agent_message = 'Oops! Something went wrong while running Browser-Use.'
|
||||
|
||||
return agent_message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error during task execution: {str(e)}')
|
||||
return f'Error during task execution: {str(e)}'
|
||||
|
||||
async def send_message(self, channel, text, thread_ts=None):
|
||||
try:
|
||||
await self.client.chat_postMessage(channel=channel, text=text, thread_ts=thread_ts)
|
||||
except SlackApiError as e:
|
||||
logger.error(f'Error sending message: {e.response["error"]}')
|
||||
|
||||
|
||||
@app.post('/slack/events')
|
||||
async def slack_events(request: Request, slack_bot: Annotated[SlackBot, Depends()]):
|
||||
try:
|
||||
if not slack_bot.signature_verifier.is_valid_request(await request.body(), dict(request.headers)):
|
||||
logger.warning('Request verification failed')
|
||||
raise HTTPException(status_code=400, detail='Request verification failed')
|
||||
|
||||
event_data = await request.json()
|
||||
logger.info(f'Received event data: {event_data}')
|
||||
if 'challenge' in event_data:
|
||||
return {'challenge': event_data['challenge']}
|
||||
|
||||
if 'event' in event_data:
|
||||
try:
|
||||
await slack_bot.handle_event(event_data.get('event'), event_data.get('event_id'))
|
||||
except Exception as e:
|
||||
logger.error(f'Error handling event: {str(e)}')
|
||||
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f'Error in slack_events: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail='Internal Server Error')
|
||||
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
from browser_use.browser import BrowserProfile
|
||||
from browser_use.llm import ChatGoogle
|
||||
from examples.integrations.slack.slack_api import SlackBot, app
|
||||
|
||||
# load credentials from environment variables
|
||||
bot_token = os.getenv('SLACK_BOT_TOKEN')
|
||||
if not bot_token:
|
||||
raise ValueError('Slack bot token not found in .env file.')
|
||||
|
||||
signing_secret = os.getenv('SLACK_SIGNING_SECRET')
|
||||
if not signing_secret:
|
||||
raise ValueError('Slack signing secret not found in .env file.')
|
||||
|
||||
api_key = os.getenv('GOOGLE_API_KEY')
|
||||
if not api_key:
|
||||
raise ValueError('GOOGLE_API_KEY is not set')
|
||||
|
||||
llm = ChatGoogle(model='gemini-2.0-flash-exp', api_key=api_key)
|
||||
|
||||
slack_bot = SlackBot(
|
||||
llm=llm, # required; instance of BaseChatModel
|
||||
bot_token=bot_token, # required; Slack bot token
|
||||
signing_secret=signing_secret, # required; Slack signing secret
|
||||
ack=True, # optional; whether to acknowledge task receipt with a message, defaults to False
|
||||
browser_profile=BrowserProfile(
|
||||
headless=True
|
||||
), # optional; useful for changing headless mode or other browser configs, defaults to headless mode
|
||||
)
|
||||
|
||||
app.dependency_overrides[SlackBot] = lambda: slack_bot
|
||||
|
||||
if __name__ == '__main__':
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run('integrations.slack.slack_api:app', host='0.0.0.0', port=3000)
|
||||
Reference in New Issue
Block a user