From ac8a466cdafad438573f71f6d548ace2fb7a9967 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Tue, 28 Jan 2025 08:07:50 +0000 Subject: [PATCH] feat(platform): Add username+password credentials type; fix email and reddit blocks (#9113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update and adds a basic credential field for use in integrations like reddit ### Changes 🏗️ - Reddit - Drops the Username and Password for reddit from the .env - Updates Reddit block with modern provider and credential system - moves clientid and secret to reading from `Settings().secrets` rather than input on the block - moves user agent to `Settings().config` - SMTP - update the block to support user password and modern credentials - Add `UserPasswordCredentials` - Default API key expiry to None explicitly to help type cohesion - add `UserPasswordCredentials` with a weird form of `bearer` which we ideally remove because `basic` is a more appropriate name. This is dependent on `Webhook _base` allowing a subset of `Credentials` - Update `Credentials` and `CredentialsType` - Fix various `OAuth2Credentials | APIKeyCredentials` -> `Credentials` mismatches between base and derived classes - Replace `router/@post(create_api_key_credentials)` with `create_credentials` which now takes a credential and is discriminated by `type` provided by the credential - UI/Frontend - Updated various pages to have saved credential types, icons, and text for User Pass Credentials - Update credential input to have an input/modals/selects for user/pass combos - Update the types to support having user/pass credentials too (we should make this more centralized) - Update Credential Providres to support user_password - Update `client.ts` to support the new streamlined credential creation method and endpoint - DX - Sort the provider names **again** TODO: - [x] Reactivate Conditionally Disabling Reddit ~~- [ ] Look into moving Webhooks base to allow subset of `Credentials` rather than requiring all webhooks to support the input of all valid `Credentials` types~~ Out of scope - [x] Figure out the `singleCredential` calculator in `credentials-input.tsx` so that it also respects User Pass credentials and isn't a logic mess ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Test with agents --------- Co-authored-by: Zamil Majdy --- autogpt_platform/backend/.env.example | 6 +- .../backend/backend/blocks/email_block.py | 86 +++++--- .../backend/backend/blocks/github/_api.py | 2 +- .../backend/backend/blocks/linear/_api.py | 2 +- .../backend/backend/blocks/reddit.py | 111 ++++++---- .../backend/backend/data/model.py | 26 ++- .../backend/backend/integrations/providers.py | 2 + .../backend/integrations/webhooks/_base.py | 2 +- .../integrations/webhooks/_manual_base.py | 4 +- .../backend/integrations/webhooks/github.py | 6 +- .../backend/server/integrations/router.py | 31 +-- .../backend/backend/util/settings.py | 7 +- .../marketplace/(user)/integrations/page.tsx | 12 +- .../frontend/src/app/profile/page.tsx | 13 +- .../integrations/credentials-input.tsx | 199 +++++++++++++++++- .../integrations/credentials-provider.tsx | 70 +++++- .../frontend/src/hooks/useCredentials.ts | 8 + .../src/lib/autogpt-server-api/client.ts | 20 +- .../src/lib/autogpt-server-api/types.ts | 26 ++- docs/content/platform/new_blocks.md | 4 +- 20 files changed, 506 insertions(+), 131 deletions(-) diff --git a/autogpt_platform/backend/.env.example b/autogpt_platform/backend/.env.example index 838cfdb283..eb4801c288 100644 --- a/autogpt_platform/backend/.env.example +++ b/autogpt_platform/backend/.env.example @@ -91,10 +91,12 @@ GROQ_API_KEY= OPEN_ROUTER_API_KEY= # Reddit +# Go to https://www.reddit.com/prefs/apps and create a new app +# Choose "script" for the type +# Fill in the redirect uri as /auth/integrations/oauth_callback, e.g. http://localhost:3000/auth/integrations/oauth_callback REDDIT_CLIENT_ID= REDDIT_CLIENT_SECRET= -REDDIT_USERNAME= -REDDIT_PASSWORD= +REDDIT_USER_AGENT="AutoGPT:1.0 (by /u/autogpt)" # Discord DISCORD_BOT_TOKEN= diff --git a/autogpt_platform/backend/backend/blocks/email_block.py b/autogpt_platform/backend/backend/blocks/email_block.py index dd63dbbcf6..4159886cee 100644 --- a/autogpt_platform/backend/backend/blocks/email_block.py +++ b/autogpt_platform/backend/backend/blocks/email_block.py @@ -1,22 +1,53 @@ import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from typing import Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, SecretStr from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema -from backend.data.model import BlockSecret, SchemaField, SecretField +from backend.data.model import ( + CredentialsField, + CredentialsMetaInput, + SchemaField, + UserPasswordCredentials, +) +from backend.integrations.providers import ProviderName + +TEST_CREDENTIALS = UserPasswordCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="smtp", + username=SecretStr("mock-smtp-username"), + password=SecretStr("mock-smtp-password"), + title="Mock SMTP credentials", +) + +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} +SMTPCredentials = UserPasswordCredentials +SMTPCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.SMTP], + Literal["user_password"], +] -class EmailCredentials(BaseModel): +def SMTPCredentialsField() -> SMTPCredentialsInput: + return CredentialsField( + description="The SMTP integration requires a username and password.", + ) + + +class SMTPConfig(BaseModel): smtp_server: str = SchemaField( - default="smtp.gmail.com", description="SMTP server address" + default="smtp.example.com", description="SMTP server address" ) smtp_port: int = SchemaField(default=25, description="SMTP port number") - smtp_username: BlockSecret = SecretField(key="smtp_username") - smtp_password: BlockSecret = SecretField(key="smtp_password") - model_config = ConfigDict(title="Email Credentials") + model_config = ConfigDict(title="SMTP Config") class SendEmailBlock(Block): @@ -30,10 +61,11 @@ class SendEmailBlock(Block): body: str = SchemaField( description="Body of the email", placeholder="Enter the email body" ) - creds: EmailCredentials = SchemaField( - description="SMTP credentials", - default=EmailCredentials(), + config: SMTPConfig = SchemaField( + description="SMTP Config", + default=SMTPConfig(), ) + credentials: SMTPCredentialsInput = SMTPCredentialsField() class Output(BlockSchema): status: str = SchemaField(description="Status of the email sending operation") @@ -43,7 +75,6 @@ class SendEmailBlock(Block): def __init__(self): super().__init__( - disabled=True, id="4335878a-394e-4e67-adf2-919877ff49ae", description="This block sends an email using the provided SMTP credentials.", categories={BlockCategory.OUTPUT}, @@ -53,25 +84,29 @@ class SendEmailBlock(Block): "to_email": "recipient@example.com", "subject": "Test Email", "body": "This is a test email.", - "creds": { + "config": { "smtp_server": "smtp.gmail.com", "smtp_port": 25, - "smtp_username": "your-email@gmail.com", - "smtp_password": "your-gmail-password", }, + "credentials": TEST_CREDENTIALS_INPUT, }, + test_credentials=TEST_CREDENTIALS, test_output=[("status", "Email sent successfully")], test_mock={"send_email": lambda *args, **kwargs: "Email sent successfully"}, ) @staticmethod def send_email( - creds: EmailCredentials, to_email: str, subject: str, body: str + config: SMTPConfig, + to_email: str, + subject: str, + body: str, + credentials: SMTPCredentials, ) -> str: - smtp_server = creds.smtp_server - smtp_port = creds.smtp_port - smtp_username = creds.smtp_username.get_secret_value() - smtp_password = creds.smtp_password.get_secret_value() + smtp_server = config.smtp_server + smtp_port = config.smtp_port + smtp_username = credentials.username.get_secret_value() + smtp_password = credentials.password.get_secret_value() msg = MIMEMultipart() msg["From"] = smtp_username @@ -86,10 +121,13 @@ class SendEmailBlock(Block): return "Email sent successfully" - def run(self, input_data: Input, **kwargs) -> BlockOutput: + def run( + self, input_data: Input, *, credentials: SMTPCredentials, **kwargs + ) -> BlockOutput: yield "status", self.send_email( - input_data.creds, - input_data.to_email, - input_data.subject, - input_data.body, + config=input_data.config, + to_email=input_data.to_email, + subject=input_data.subject, + body=input_data.body, + credentials=credentials, ) diff --git a/autogpt_platform/backend/backend/blocks/github/_api.py b/autogpt_platform/backend/backend/blocks/github/_api.py index 6ec91eeb37..72d25d9307 100644 --- a/autogpt_platform/backend/backend/blocks/github/_api.py +++ b/autogpt_platform/backend/backend/blocks/github/_api.py @@ -30,7 +30,7 @@ def _convert_to_api_url(url: str) -> str: def _get_headers(credentials: GithubCredentials) -> dict[str, str]: return { - "Authorization": credentials.bearer(), + "Authorization": credentials.auth_header(), "Accept": "application/vnd.github.v3+json", } diff --git a/autogpt_platform/backend/backend/blocks/linear/_api.py b/autogpt_platform/backend/backend/blocks/linear/_api.py index 4639975e8e..c43f46fa70 100644 --- a/autogpt_platform/backend/backend/blocks/linear/_api.py +++ b/autogpt_platform/backend/backend/blocks/linear/_api.py @@ -40,7 +40,7 @@ class LinearClient: "Content-Type": "application/json", } if credentials: - headers["Authorization"] = credentials.bearer() + headers["Authorization"] = credentials.auth_header() self._requests = Requests( extra_headers=headers, diff --git a/autogpt_platform/backend/backend/blocks/reddit.py b/autogpt_platform/backend/backend/blocks/reddit.py index 53c6e02a32..b3dca4ca74 100644 --- a/autogpt_platform/backend/backend/blocks/reddit.py +++ b/autogpt_platform/backend/backend/blocks/reddit.py @@ -1,22 +1,48 @@ from datetime import datetime, timezone -from typing import Iterator +from typing import Iterator, Literal import praw -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, SecretStr from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema -from backend.data.model import BlockSecret, SchemaField, SecretField +from backend.data.model import ( + CredentialsField, + CredentialsMetaInput, + SchemaField, + UserPasswordCredentials, +) +from backend.integrations.providers import ProviderName from backend.util.mock import MockObject +from backend.util.settings import Settings + +RedditCredentials = UserPasswordCredentials +RedditCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.REDDIT], + Literal["user_password"], +] -class RedditCredentials(BaseModel): - client_id: BlockSecret = SecretField(key="reddit_client_id") - client_secret: BlockSecret = SecretField(key="reddit_client_secret") - username: BlockSecret = SecretField(key="reddit_username") - password: BlockSecret = SecretField(key="reddit_password") - user_agent: str = "AutoGPT:1.0 (by /u/autogpt)" +def RedditCredentialsField() -> RedditCredentialsInput: + """Creates a Reddit credentials input on a block.""" + return CredentialsField( + description="The Reddit integration requires a username and password.", + ) - model_config = ConfigDict(title="Reddit Credentials") + +TEST_CREDENTIALS = UserPasswordCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="reddit", + username=SecretStr("mock-reddit-username"), + password=SecretStr("mock-reddit-password"), + title="Mock Reddit credentials", +) + +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} class RedditPost(BaseModel): @@ -31,13 +57,16 @@ class RedditComment(BaseModel): comment: str +settings = Settings() + + def get_praw(creds: RedditCredentials) -> praw.Reddit: client = praw.Reddit( - client_id=creds.client_id.get_secret_value(), - client_secret=creds.client_secret.get_secret_value(), + client_id=settings.secrets.reddit_client_id, + client_secret=settings.secrets.reddit_client_secret, username=creds.username.get_secret_value(), password=creds.password.get_secret_value(), - user_agent=creds.user_agent, + user_agent=settings.config.reddit_user_agent, ) me = client.user.me() if not me: @@ -48,11 +77,11 @@ def get_praw(creds: RedditCredentials) -> praw.Reddit: class GetRedditPostsBlock(Block): class Input(BlockSchema): - subreddit: str = SchemaField(description="Subreddit name") - creds: RedditCredentials = SchemaField( - description="Reddit credentials", - default=RedditCredentials(), + subreddit: str = SchemaField( + description="Subreddit name, excluding the /r/ prefix", + default="writingprompts", ) + credentials: RedditCredentialsInput = RedditCredentialsField() last_minutes: int | None = SchemaField( description="Post time to stop minutes ago while fetching posts", default=None, @@ -70,20 +99,18 @@ class GetRedditPostsBlock(Block): def __init__(self): super().__init__( - disabled=True, id="c6731acb-4285-4ee1-bc9b-03d0766c370f", description="This block fetches Reddit posts from a defined subreddit name.", categories={BlockCategory.SOCIAL}, + disabled=( + not settings.secrets.reddit_client_id + or not settings.secrets.reddit_client_secret + ), input_schema=GetRedditPostsBlock.Input, output_schema=GetRedditPostsBlock.Output, + test_credentials=TEST_CREDENTIALS, test_input={ - "creds": { - "client_id": "client_id", - "client_secret": "client_secret", - "username": "username", - "password": "password", - "user_agent": "user_agent", - }, + "credentials": TEST_CREDENTIALS_INPUT, "subreddit": "subreddit", "last_post": "id3", "post_limit": 2, @@ -103,7 +130,7 @@ class GetRedditPostsBlock(Block): ), ], test_mock={ - "get_posts": lambda _: [ + "get_posts": lambda input_data, credentials: [ MockObject(id="id1", title="title1", selftext="body1"), MockObject(id="id2", title="title2", selftext="body2"), MockObject(id="id3", title="title2", selftext="body2"), @@ -112,14 +139,18 @@ class GetRedditPostsBlock(Block): ) @staticmethod - def get_posts(input_data: Input) -> Iterator[praw.reddit.Submission]: - client = get_praw(input_data.creds) + def get_posts( + input_data: Input, *, credentials: RedditCredentials + ) -> Iterator[praw.reddit.Submission]: + client = get_praw(credentials) subreddit = client.subreddit(input_data.subreddit) return subreddit.new(limit=input_data.post_limit or 10) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + def run( + self, input_data: Input, *, credentials: RedditCredentials, **kwargs + ) -> BlockOutput: current_time = datetime.now(tz=timezone.utc) - for post in self.get_posts(input_data): + for post in self.get_posts(input_data=input_data, credentials=credentials): if input_data.last_minutes: post_datetime = datetime.fromtimestamp( post.created_utc, tz=timezone.utc @@ -141,9 +172,7 @@ class GetRedditPostsBlock(Block): class PostRedditCommentBlock(Block): class Input(BlockSchema): - creds: RedditCredentials = SchemaField( - description="Reddit credentials", default=RedditCredentials() - ) + credentials: RedditCredentialsInput = RedditCredentialsField() data: RedditComment = SchemaField(description="Reddit comment") class Output(BlockSchema): @@ -156,7 +185,15 @@ class PostRedditCommentBlock(Block): categories={BlockCategory.SOCIAL}, input_schema=PostRedditCommentBlock.Input, output_schema=PostRedditCommentBlock.Output, - test_input={"data": {"post_id": "id", "comment": "comment"}}, + disabled=( + not settings.secrets.reddit_client_id + or not settings.secrets.reddit_client_secret + ), + test_credentials=TEST_CREDENTIALS, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "data": {"post_id": "id", "comment": "comment"}, + }, test_output=[("comment_id", "dummy_comment_id")], test_mock={"reply_post": lambda creds, comment: "dummy_comment_id"}, ) @@ -170,5 +207,7 @@ class PostRedditCommentBlock(Block): raise ValueError("Failed to post comment.") return new_comment.id - def run(self, input_data: Input, **kwargs) -> BlockOutput: - yield "comment_id", self.reply_post(input_data.creds, input_data.data) + def run( + self, input_data: Input, *, credentials: RedditCredentials, **kwargs + ) -> BlockOutput: + yield "comment_id", self.reply_post(credentials, input_data.data) diff --git a/autogpt_platform/backend/backend/data/model.py b/autogpt_platform/backend/backend/data/model.py index 3b636ce7ea..619dcd8841 100644 --- a/autogpt_platform/backend/backend/data/model.py +++ b/autogpt_platform/backend/backend/data/model.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import logging from typing import ( TYPE_CHECKING, @@ -199,27 +200,42 @@ class OAuth2Credentials(_BaseCredentials): scopes: list[str] metadata: dict[str, Any] = Field(default_factory=dict) - def bearer(self) -> str: + def auth_header(self) -> str: return f"Bearer {self.access_token.get_secret_value()}" class APIKeyCredentials(_BaseCredentials): type: Literal["api_key"] = "api_key" api_key: SecretStr - expires_at: Optional[int] + expires_at: Optional[int] = Field( + default=None, + description="Unix timestamp (seconds) indicating when the API key expires (if at all)", + ) """Unix timestamp (seconds) indicating when the API key expires (if at all)""" - def bearer(self) -> str: + def auth_header(self) -> str: return f"Bearer {self.api_key.get_secret_value()}" +class UserPasswordCredentials(_BaseCredentials): + type: Literal["user_password"] = "user_password" + username: SecretStr + password: SecretStr + + def auth_header(self) -> str: + # Converting the string to bytes using encode() + # Base64 encoding it with base64.b64encode() + # Converting the resulting bytes back to a string with decode() + return f"Basic {base64.b64encode(f'{self.username.get_secret_value()}:{self.password.get_secret_value()}'.encode()).decode()}" + + Credentials = Annotated[ - OAuth2Credentials | APIKeyCredentials, + OAuth2Credentials | APIKeyCredentials | UserPasswordCredentials, Field(discriminator="type"), ] -CredentialsType = Literal["api_key", "oauth2"] +CredentialsType = Literal["api_key", "oauth2", "user_password"] class OAuthState(BaseModel): diff --git a/autogpt_platform/backend/backend/integrations/providers.py b/autogpt_platform/backend/backend/integrations/providers.py index 95751e92df..c8cebe0a52 100644 --- a/autogpt_platform/backend/backend/integrations/providers.py +++ b/autogpt_platform/backend/backend/integrations/providers.py @@ -26,9 +26,11 @@ class ProviderName(str, Enum): OPENWEATHERMAP = "openweathermap" OPEN_ROUTER = "open_router" PINECONE = "pinecone" + REDDIT = "reddit" REPLICATE = "replicate" REVID = "revid" SLANT3D = "slant3d" + SMTP = "smtp" TWITTER = "twitter" UNREAL_SPEECH = "unreal_speech" # --8<-- [end:ProviderName] diff --git a/autogpt_platform/backend/backend/integrations/webhooks/_base.py b/autogpt_platform/backend/backend/integrations/webhooks/_base.py index 4d6066d40d..be3a710552 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/_base.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/_base.py @@ -168,7 +168,7 @@ class BaseWebhooksManager(ABC, Generic[WT]): id = str(uuid4()) secret = secrets.token_hex(32) - provider_name = self.PROVIDER_NAME + provider_name: ProviderName = self.PROVIDER_NAME ingress_url = webhook_ingress_url(provider_name=provider_name, webhook_id=id) if register: if not credentials: diff --git a/autogpt_platform/backend/backend/integrations/webhooks/_manual_base.py b/autogpt_platform/backend/backend/integrations/webhooks/_manual_base.py index 0e1cc0dc4d..cf749a3cf9 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/_manual_base.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/_manual_base.py @@ -1,7 +1,7 @@ import logging from backend.data import integrations -from backend.data.model import APIKeyCredentials, Credentials, OAuth2Credentials +from backend.data.model import Credentials from ._base import WT, BaseWebhooksManager @@ -25,6 +25,6 @@ class ManualWebhookManagerBase(BaseWebhooksManager[WT]): async def _deregister_webhook( self, webhook: integrations.Webhook, - credentials: OAuth2Credentials | APIKeyCredentials, + credentials: Credentials, ) -> None: pass diff --git a/autogpt_platform/backend/backend/integrations/webhooks/github.py b/autogpt_platform/backend/backend/integrations/webhooks/github.py index 8bf5639eb2..6a39192045 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/github.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/github.py @@ -67,7 +67,7 @@ class GithubWebhooksManager(BaseWebhooksManager): headers = { **self.GITHUB_API_DEFAULT_HEADERS, - "Authorization": credentials.bearer(), + "Authorization": credentials.auth_header(), } repo, github_hook_id = webhook.resource, webhook.provider_webhook_id @@ -96,7 +96,7 @@ class GithubWebhooksManager(BaseWebhooksManager): headers = { **self.GITHUB_API_DEFAULT_HEADERS, - "Authorization": credentials.bearer(), + "Authorization": credentials.auth_header(), } webhook_data = { "name": "web", @@ -142,7 +142,7 @@ class GithubWebhooksManager(BaseWebhooksManager): headers = { **self.GITHUB_API_DEFAULT_HEADERS, - "Authorization": credentials.bearer(), + "Authorization": credentials.auth_header(), } if webhook_type == self.WebhookType.REPO: diff --git a/autogpt_platform/backend/backend/server/integrations/router.py b/autogpt_platform/backend/backend/server/integrations/router.py index b85a551375..9a5267e2f0 100644 --- a/autogpt_platform/backend/backend/server/integrations/router.py +++ b/autogpt_platform/backend/backend/server/integrations/router.py @@ -2,7 +2,7 @@ import logging from typing import TYPE_CHECKING, Annotated, Literal from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request -from pydantic import BaseModel, Field, SecretStr +from pydantic import BaseModel, Field from backend.data.graph import set_node_webhook from backend.data.integrations import ( @@ -12,12 +12,7 @@ from backend.data.integrations import ( publish_webhook_event, wait_for_webhook_event, ) -from backend.data.model import ( - APIKeyCredentials, - Credentials, - CredentialsType, - OAuth2Credentials, -) +from backend.data.model import Credentials, CredentialsType, OAuth2Credentials from backend.executor.manager import ExecutionManager from backend.integrations.creds_manager import IntegrationCredentialsManager from backend.integrations.oauth import HANDLERS_BY_NAME @@ -204,31 +199,21 @@ def get_credential( @router.post("/{provider}/credentials", status_code=201) -def create_api_key_credentials( +def create_credentials( user_id: Annotated[str, Depends(get_user_id)], provider: Annotated[ ProviderName, Path(title="The provider to create credentials for") ], - api_key: Annotated[str, Body(title="The API key to store")], - title: Annotated[str, Body(title="Optional title for the credentials")], - expires_at: Annotated[ - int | None, Body(title="Unix timestamp when the key expires") - ] = None, -) -> APIKeyCredentials: - new_credentials = APIKeyCredentials( - provider=provider, - api_key=SecretStr(api_key), - title=title, - expires_at=expires_at, - ) - + credentials: Credentials, +) -> Credentials: + credentials.provider = provider try: - creds_manager.create(user_id, new_credentials) + creds_manager.create(user_id, credentials) except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to store credentials: {str(e)}" ) - return new_credentials + return credentials class CredentialsDeletionResponse(BaseModel): diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 8c33c14590..24f3ca6d37 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -157,6 +157,11 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): description="The name of the Google Cloud Storage bucket for media files", ) + reddit_user_agent: str = Field( + default="AutoGPT:1.0 (by /u/autogpt)", + description="The user agent for the Reddit API", + ) + scheduler_db_pool_size: int = Field( default=3, description="The pool size for the scheduler database connection pool", @@ -280,8 +285,6 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): reddit_client_id: str = Field(default="", description="Reddit client ID") reddit_client_secret: str = Field(default="", description="Reddit client secret") - reddit_username: str = Field(default="", description="Reddit username") - reddit_password: str = Field(default="", description="Reddit password") openweathermap_api_key: str = Field( default="", description="OpenWeatherMap API key" diff --git a/autogpt_platform/frontend/src/app/marketplace/(user)/integrations/page.tsx b/autogpt_platform/frontend/src/app/marketplace/(user)/integrations/page.tsx index 644c2d9ce1..330b470f81 100644 --- a/autogpt_platform/frontend/src/app/marketplace/(user)/integrations/page.tsx +++ b/autogpt_platform/frontend/src/app/marketplace/(user)/integrations/page.tsx @@ -124,14 +124,22 @@ export default function PrivatePage() { const allCredentials = providers ? Object.values(providers).flatMap((provider) => - [...provider.savedOAuthCredentials, ...provider.savedApiKeys] + [ + ...provider.savedOAuthCredentials, + ...provider.savedApiKeys, + ...provider.savedUserPasswordCredentials, + ] .filter((cred) => !hiddenCredentials.includes(cred.id)) .map((credentials) => ({ ...credentials, provider: provider.provider, providerName: provider.providerName, ProviderIcon: providerIcons[provider.provider], - TypeIcon: { oauth2: IconUser, api_key: IconKey }[credentials.type], + TypeIcon: { + oauth2: IconUser, + api_key: IconKey, + user_password: IconKey, + }[credentials.type], })), ) : []; diff --git a/autogpt_platform/frontend/src/app/profile/page.tsx b/autogpt_platform/frontend/src/app/profile/page.tsx index d5960c2fdb..a4fb512663 100644 --- a/autogpt_platform/frontend/src/app/profile/page.tsx +++ b/autogpt_platform/frontend/src/app/profile/page.tsx @@ -124,14 +124,22 @@ export default function PrivatePage() { const allCredentials = providers ? Object.values(providers).flatMap((provider) => - [...provider.savedOAuthCredentials, ...provider.savedApiKeys] + [ + ...provider.savedOAuthCredentials, + ...provider.savedApiKeys, + ...provider.savedUserPasswordCredentials, + ] .filter((cred) => !hiddenCredentials.includes(cred.id)) .map((credentials) => ({ ...credentials, provider: provider.provider, providerName: provider.providerName, ProviderIcon: providerIcons[provider.provider], - TypeIcon: { oauth2: IconUser, api_key: IconKey }[credentials.type], + TypeIcon: { + oauth2: IconUser, + api_key: IconKey, + user_password: IconKey, + }[credentials.type], })), ) : []; @@ -176,6 +184,7 @@ export default function PrivatePage() { { oauth2: "OAuth2 credentials", api_key: "API key", + user_password: "User password", }[cred.type] }{" "} - {cred.id} diff --git a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx index 93afa31c6e..340fc6cb44 100644 --- a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx +++ b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx @@ -75,7 +75,9 @@ export const providerIcons: Record< open_router: fallbackIcon, pinecone: fallbackIcon, slant3d: fallbackIcon, + smtp: fallbackIcon, replicate: fallbackIcon, + reddit: fallbackIcon, fal: fallbackIcon, revid: fallbackIcon, twitter: FaTwitter, @@ -107,6 +109,10 @@ export const CredentialsInput: FC<{ const credentials = useCredentials(selfKey); const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] = useState(false); + const [ + isUserPasswordCredentialsModalOpen, + setUserPasswordCredentialsModalOpen, + ] = useState(false); const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false); const [oAuthPopupController, setOAuthPopupController] = useState(null); @@ -122,8 +128,10 @@ export const CredentialsInput: FC<{ providerName, supportsApiKey, supportsOAuth2, + supportsUserPassword, savedApiKeys, savedOAuthCredentials, + savedUserPasswordCredentials, oAuthCallback, } = credentials; @@ -237,6 +245,17 @@ export const CredentialsInput: FC<{ providerName={providerName} /> )} + {supportsUserPassword && ( + setUserPasswordCredentialsModalOpen(false)} + onCredentialsCreate={(creds) => { + onSelectCredentials(creds); + setUserPasswordCredentialsModalOpen(false); + }} + /> + )} ); @@ -245,13 +264,18 @@ export const CredentialsInput: FC<{ selectedCredentials && !savedApiKeys .concat(savedOAuthCredentials) + .concat(savedUserPasswordCredentials) .some((c) => c.id === selectedCredentials.id) ) { onSelectCredentials(undefined); } // No saved credentials yet - if (savedApiKeys.length === 0 && savedOAuthCredentials.length === 0) { + if ( + savedApiKeys.length === 0 && + savedOAuthCredentials.length === 0 && + savedUserPasswordCredentials.length === 0 + ) { return ( <>
@@ -273,6 +297,12 @@ export const CredentialsInput: FC<{ Enter API key )} + {supportsUserPassword && ( + + )}
{modals} {oAuthError && ( @@ -282,12 +312,29 @@ export const CredentialsInput: FC<{ ); } - const singleCredential = - savedApiKeys.length === 1 && savedOAuthCredentials.length === 0 - ? savedApiKeys[0] - : savedOAuthCredentials.length === 1 && savedApiKeys.length === 0 - ? savedOAuthCredentials[0] - : null; + const getCredentialCounts = () => ({ + apiKeys: savedApiKeys.length, + oauth: savedOAuthCredentials.length, + userPass: savedUserPasswordCredentials.length, + }); + + const getSingleCredential = () => { + const counts = getCredentialCounts(); + const totalCredentials = Object.values(counts).reduce( + (sum, count) => sum + count, + 0, + ); + + if (totalCredentials !== 1) return null; + + if (counts.apiKeys === 1) return savedApiKeys[0]; + if (counts.oauth === 1) return savedOAuthCredentials[0]; + if (counts.userPass === 1) return savedUserPasswordCredentials[0]; + + return null; + }; + + const singleCredential = getSingleCredential(); if (singleCredential) { if (!selectedCredentials) { @@ -311,6 +358,7 @@ export const CredentialsInput: FC<{ } else { const selectedCreds = savedApiKeys .concat(savedOAuthCredentials) + .concat(savedUserPasswordCredentials) .find((c) => c.id == newValue)!; onSelectCredentials({ @@ -349,6 +397,13 @@ export const CredentialsInput: FC<{ {credentials.title} ))} + {savedUserPasswordCredentials.map((credentials, index) => ( + + + + {credentials.title} + + ))} {supportsOAuth2 && ( @@ -362,6 +417,12 @@ export const CredentialsInput: FC<{ Add new API key )} + {supportsUserPassword && ( + + + Add new user password + + )} {modals} @@ -508,6 +569,130 @@ export const APIKeyCredentialsModal: FC<{ ); }; +export const UserPasswordCredentialsModal: FC<{ + credentialsFieldName: string; + open: boolean; + onClose: () => void; + onCredentialsCreate: (creds: CredentialsMetaInput) => void; +}> = ({ credentialsFieldName, open, onClose, onCredentialsCreate }) => { + const credentials = useCredentials(credentialsFieldName); + + const formSchema = z.object({ + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), + title: z.string().min(1, "Name is required"), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + username: "", + password: "", + title: "", + }, + }); + + if ( + !credentials || + credentials.isLoading || + !credentials.supportsUserPassword + ) { + return null; + } + + const { schema, provider, providerName, createUserPasswordCredentials } = + credentials; + + async function onSubmit(values: z.infer) { + const newCredentials = await createUserPasswordCredentials({ + username: values.username, + password: values.password, + title: values.title, + }); + onCredentialsCreate({ + provider, + id: newCredentials.id, + type: "user_password", + title: newCredentials.title, + }); + } + + return ( + { + if (!open) onClose(); + }} + > + + + + Add new username & password for {providerName} + + +
+ + ( + + Username + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Name + + + + + + )} + /> + + + +
+
+ ); +}; + export const OAuth2FlowWaitingModal: FC<{ open: boolean; onClose: () => void; diff --git a/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx b/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx index 23a840e794..ab8f0a5a9b 100644 --- a/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx +++ b/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx @@ -5,6 +5,7 @@ import { CredentialsMetaResponse, CredentialsProviderName, PROVIDER_NAMES, + UserPasswordCredentials, } from "@/lib/autogpt-server-api"; import { useBackendAPI } from "@/lib/autogpt-server-api/context"; import { createContext, useCallback, useEffect, useState } from "react"; @@ -20,10 +21,13 @@ const providerDisplayNames: Record = { discord: "Discord", d_id: "D-ID", e2b: "E2B", + exa: "Exa", + fal: "FAL", github: "GitHub", google: "Google", google_maps: "Google Maps", groq: "Groq", + hubspot: "Hubspot", ideogram: "Ideogram", jina: "Jina", linear: "Linear", @@ -36,13 +40,12 @@ const providerDisplayNames: Record = { open_router: "Open Router", pinecone: "Pinecone", slant3d: "Slant3D", + smtp: "SMTP", + reddit: "Reddit", replicate: "Replicate", - fal: "FAL", revid: "Rev.ID", twitter: "Twitter", unreal_speech: "Unreal Speech", - exa: "Exa", - hubspot: "Hubspot", } as const; // --8<-- [end:CredentialsProviderNames] @@ -51,11 +54,17 @@ type APIKeyCredentialsCreatable = Omit< "id" | "provider" | "type" >; +type UserPasswordCredentialsCreatable = Omit< + UserPasswordCredentials, + "id" | "provider" | "type" +>; + export type CredentialsProviderData = { provider: CredentialsProviderName; providerName: string; savedApiKeys: CredentialsMetaResponse[]; savedOAuthCredentials: CredentialsMetaResponse[]; + savedUserPasswordCredentials: CredentialsMetaResponse[]; oAuthCallback: ( code: string, state_token: string, @@ -63,6 +72,9 @@ export type CredentialsProviderData = { createAPIKeyCredentials: ( credentials: APIKeyCredentialsCreatable, ) => Promise; + createUserPasswordCredentials: ( + credentials: UserPasswordCredentialsCreatable, + ) => Promise; deleteCredentials: ( id: string, force?: boolean, @@ -107,6 +119,11 @@ export default function CredentialsProvider({ ...updatedProvider.savedOAuthCredentials, credentials, ]; + } else if (credentials.type === "user_password") { + updatedProvider.savedUserPasswordCredentials = [ + ...updatedProvider.savedUserPasswordCredentials, + credentials, + ]; } return { @@ -148,6 +165,22 @@ export default function CredentialsProvider({ [api, addCredentials], ); + /** Wraps `BackendAPI.createUserPasswordCredentials`, and adds the result to the internal credentials store. */ + const createUserPasswordCredentials = useCallback( + async ( + provider: CredentialsProviderName, + credentials: UserPasswordCredentialsCreatable, + ): Promise => { + const credsMeta = await api.createUserPasswordCredentials({ + provider, + ...credentials, + }); + addCredentials(provider, credsMeta); + return credsMeta; + }, + [api, addCredentials], + ); + /** Wraps `BackendAPI.deleteCredentials`, and removes the credentials from the internal store. */ const deleteCredentials = useCallback( async ( @@ -172,7 +205,10 @@ export default function CredentialsProvider({ updatedProvider.savedOAuthCredentials.filter( (cred) => cred.id !== id, ); - + updatedProvider.savedUserPasswordCredentials = + updatedProvider.savedUserPasswordCredentials.filter( + (cred) => cred.id !== id, + ); return { ...prev, [provider]: updatedProvider, @@ -191,12 +227,18 @@ export default function CredentialsProvider({ const credentialsByProvider = response.reduce( (acc, cred) => { if (!acc[cred.provider]) { - acc[cred.provider] = { oauthCreds: [], apiKeys: [] }; + acc[cred.provider] = { + oauthCreds: [], + apiKeys: [], + userPasswordCreds: [], + }; } if (cred.type === "oauth2") { acc[cred.provider].oauthCreds.push(cred); } else if (cred.type === "api_key") { acc[cred.provider].apiKeys.push(cred); + } else if (cred.type === "user_password") { + acc[cred.provider].userPasswordCreds.push(cred); } return acc; }, @@ -205,6 +247,7 @@ export default function CredentialsProvider({ { oauthCreds: CredentialsMetaResponse[]; apiKeys: CredentialsMetaResponse[]; + userPasswordCreds: CredentialsMetaResponse[]; } >, ); @@ -221,6 +264,8 @@ export default function CredentialsProvider({ savedApiKeys: credentialsByProvider[provider]?.apiKeys ?? [], savedOAuthCredentials: credentialsByProvider[provider]?.oauthCreds ?? [], + savedUserPasswordCredentials: + credentialsByProvider[provider]?.userPasswordCreds ?? [], oAuthCallback: (code: string, state_token: string) => oAuthCallback( provider as CredentialsProviderName, @@ -234,6 +279,13 @@ export default function CredentialsProvider({ provider as CredentialsProviderName, credentials, ), + createUserPasswordCredentials: ( + credentials: UserPasswordCredentialsCreatable, + ) => + createUserPasswordCredentials( + provider as CredentialsProviderName, + credentials, + ), deleteCredentials: (id: string, force: boolean = false) => deleteCredentials( provider as CredentialsProviderName, @@ -246,7 +298,13 @@ export default function CredentialsProvider({ })); }); }); - }, [api, createAPIKeyCredentials, deleteCredentials, oAuthCallback]); + }, [ + api, + createAPIKeyCredentials, + createUserPasswordCredentials, + deleteCredentials, + oAuthCallback, + ]); return ( diff --git a/autogpt_platform/frontend/src/hooks/useCredentials.ts b/autogpt_platform/frontend/src/hooks/useCredentials.ts index 867b3417ac..b897c26123 100644 --- a/autogpt_platform/frontend/src/hooks/useCredentials.ts +++ b/autogpt_platform/frontend/src/hooks/useCredentials.ts @@ -17,12 +17,14 @@ export type CredentialsData = schema: BlockIOCredentialsSubSchema; supportsApiKey: boolean; supportsOAuth2: boolean; + supportsUserPassword: boolean; isLoading: true; } | (CredentialsProviderData & { schema: BlockIOCredentialsSubSchema; supportsApiKey: boolean; supportsOAuth2: boolean; + supportsUserPassword: boolean; isLoading: false; }); @@ -72,6 +74,8 @@ export default function useCredentials( const supportsApiKey = credentialsSchema.credentials_types.includes("api_key"); const supportsOAuth2 = credentialsSchema.credentials_types.includes("oauth2"); + const supportsUserPassword = + credentialsSchema.credentials_types.includes("user_password"); // No provider means maybe it's still loading if (!provider) { @@ -93,13 +97,17 @@ export default function useCredentials( ) : provider.savedOAuthCredentials; + const savedUserPasswordCredentials = provider.savedUserPasswordCredentials; + return { ...provider, provider: providerName, schema: credentialsSchema, supportsApiKey, supportsOAuth2, + supportsUserPassword, savedOAuthCredentials, + savedUserPasswordCredentials, isLoading: false, }; } diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts index 002da433e4..b813afca48 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts @@ -15,7 +15,6 @@ import { GraphUpdateable, NodeExecutionResult, MyAgentsResponse, - OAuth2Credentials, ProfileDetails, User, StoreAgentsResponse, @@ -29,6 +28,8 @@ import { StoreReview, ScheduleCreatable, Schedule, + UserPasswordCredentials, + Credentials, APIKeyPermission, CreateAPIKeyResponse, APIKey, @@ -203,7 +204,17 @@ export default class BackendAPI { return this._request( "POST", `/integrations/${credentials.provider}/credentials`, - credentials, + { ...credentials, type: "api_key" }, + ); + } + + createUserPasswordCredentials( + credentials: Omit, + ): Promise { + return this._request( + "POST", + `/integrations/${credentials.provider}/credentials`, + { ...credentials, type: "user_password" }, ); } @@ -215,10 +226,7 @@ export default class BackendAPI { ); } - getCredentials( - provider: string, - id: string, - ): Promise { + getCredentials(provider: string, id: string): Promise { return this._get(`/integrations/${provider}/credentials/${id}`); } diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index cd839d2cd1..dc046fd583 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -97,7 +97,12 @@ export type BlockIOBooleanSubSchema = BlockIOSubSchemaMeta & { default?: boolean; }; -export type CredentialsType = "api_key" | "oauth2"; +export type CredentialsType = "api_key" | "oauth2" | "user_password"; + +export type Credentials = + | APIKeyCredentials + | OAuth2Credentials + | UserPasswordCredentials; // --8<-- [start:BlockIOCredentialsSubSchema] export const PROVIDER_NAMES = { @@ -105,10 +110,13 @@ export const PROVIDER_NAMES = { D_ID: "d_id", DISCORD: "discord", E2B: "e2b", + EXA: "exa", + FAL: "fal", GITHUB: "github", GOOGLE: "google", GOOGLE_MAPS: "google_maps", GROQ: "groq", + HUBSPOT: "hubspot", IDEOGRAM: "ideogram", JINA: "jina", LINEAR: "linear", @@ -121,13 +129,12 @@ export const PROVIDER_NAMES = { OPEN_ROUTER: "open_router", PINECONE: "pinecone", SLANT3D: "slant3d", + SMTP: "smtp", + TWITTER: "twitter", REPLICATE: "replicate", - FAL: "fal", + REDDIT: "reddit", REVID: "revid", UNREAL_SPEECH: "unreal_speech", - EXA: "exa", - HUBSPOT: "hubspot", - TWITTER: "twitter", } as const; // --8<-- [end:BlockIOCredentialsSubSchema] @@ -323,8 +330,15 @@ export type APIKeyCredentials = BaseCredentials & { expires_at?: number; }; +export type UserPasswordCredentials = BaseCredentials & { + type: "user_password"; + title: string; + username: string; + password: string; +}; + /* Mirror of backend/data/integrations.py:Webhook */ -type Webhook = { +export type Webhook = { id: string; url: string; provider: CredentialsProviderName; diff --git a/docs/content/platform/new_blocks.md b/docs/content/platform/new_blocks.md index 5d6bdb171a..4f887d213d 100644 --- a/docs/content/platform/new_blocks.md +++ b/docs/content/platform/new_blocks.md @@ -257,13 +257,13 @@ response = requests.post( ) ``` -or use the shortcut `credentials.bearer()`: +or use the shortcut `credentials.auth_header()`: ```python # credentials: APIKeyCredentials | OAuth2Credentials response = requests.post( url, - headers={"Authorization": credentials.bearer()}, + headers={"Authorization": credentials.auth_header()}, ) ```